diff --git a/app/Extension/Traits/GeneratesComponentManifest.php b/app/Extension/Traits/GeneratesComponentManifest.php
index 1e77a061..5d87922b 100644
--- a/app/Extension/Traits/GeneratesComponentManifest.php
+++ b/app/Extension/Traits/GeneratesComponentManifest.php
@@ -53,7 +53,11 @@ trait GeneratesComponentManifest
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
- $written = $json !== false && file_put_contents($outputPath, $json.PHP_EOL) !== false;
+ // 종결 개행은 "\n" 고정 — PHP_EOL 은 Windows 에서 "\r\n" 이라, 같은 소스를 빌드해도
+ // 빌드한 OS 에 따라 산출물의 마지막 바이트가 달라진다. components.json 은 Git 추적
+ // 대상이므로 그 차이가 매 빌드마다 변경으로 잡히는데, 줄 내용이 같아 diff 는 비어
+ // 보인다 — 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
+ $written = $json !== false && file_put_contents($outputPath, $json."\n") !== false;
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
diff --git a/docs/frontend/state-management.md b/docs/frontend/state-management.md
index 1a26c1e6..0101fc9e 100644
--- a/docs/frontend/state-management.md
+++ b/docs/frontend/state-management.md
@@ -163,7 +163,6 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
- **예외**: `selfManaged: true` 명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용)
- **pending 스냅샷**: 자동바인딩은 `__g7PendingLocalState` 에 "지금 화면과 같은 전체 스냅샷" 을 싣는다. 이 값이 뒤이은 `setLocal` 의 base 가 되므로, 저장소 A 스냅샷을 그대로 실으면 B 에만 있던 값(selfManaged 플러그인이 쓴 편집기 본문 등)이 사라진다. 그래서 렌더러가 화면을 만드는 순서(`dataContext._local → dynamicState → __g7ForcedLocalFields`)를 그대로 따라 합성한다 (engine-v1.63.4)
-
### 엔진 수정 시 금지 사항 (CRITICAL)
```text
diff --git a/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md b/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md
index 1c51ba7c..5472ed84 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md
+++ b/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md
@@ -171,7 +171,7 @@
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 15개 | `plugins/_bundled/sirsoft-ckeditor5/tests` |
-| Vitest | 10개 | `vitest.config.ts` |
+| Vitest | 11개 | `vitest.config.ts` |
| Playwright | 4개 | `tests/Playwright` |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
diff --git a/plugins/_bundled/sirsoft-ckeditor5/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-ckeditor5/dist/js/plugin.iife.js
index 9fcb8b35..f48da0b4 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/dist/js/plugin.iife.js
+++ b/plugins/_bundled/sirsoft-ckeditor5/dist/js/plugin.iife.js
@@ -1,4 +1,4 @@
-var SirsoftCkeditor5=function(D){"use strict";const E=new Map;let Z=!1;function _t(){return Z}function K(t){Z=t}const O=new Map;function tt(t,o){const e=O.get(t)??[];e.push(o),O.set(t,e)}function et(t){(O.get(t)??[]).forEach(o=>{try{o.stop()}catch{}}),O.delete(t)}const St=400,vt=1e4;function ot(t,o){const{name:e,locale:n,multilingual:r}=o;let a=location.pathname,i=null,s=!1,k=null,l=0;const c=()=>{const u=window.G7Core?.state?.getLocal?.()?.form;if(!u||!e)return;const f=r?u[e]?.[n]:u[e];return typeof f=="string"?f:void 0},b=window.setInterval(()=>{let d;try{d=t.getData()}catch{window.clearInterval(b);return}location.pathname!==a&&(a=location.pathname,i=null,s=!0,l=Date.now(),k=d);const u=c();if(u!==void 0){if(s){if(u===k){Date.now()-l>vt&&(s=!1,k=null);return}if(u!==d){K(!0);try{t.setData(u)}finally{K(!1)}}s=!1,k=null,i=u;return}if(u===i||u===d){i=u;return}}},St);return{shouldEmit:()=>!s,noteEmitted:d=>{i=d},stop:()=>window.clearInterval(b)}}const Et="43.3.1",F="sirsoft-ckeditor5",H=`dist/vendor/ckeditor5/${Et}`,q="ckeditor5-vendor-css";function V(){const t=window?.G7Core?.asset;if(!t||typeof t.plugin!="function")throw new Error("G7Core.asset is unavailable — core 7.0.10 or later is required to resolve bundled CKEditor5 assets");return t}function xt(){return V().plugin(F,`${H}/ckeditor5.umd.js`)}function nt(){return V().plugin(F,`${H}/ckeditor5.css`)}function Lt(t){return V().plugin(F,`${H}/translations/${t}.umd.js`)}const T="data-ckeditor5-fallback",W=new WeakMap;function j(t,o){const e=window.G7Core;if(!e?.state?.setLocal||!t)return;const n={[`form.${t}_mode`]:"text",hasChanges:!0};if(typeof o=="string")n[`form.${t}`]=o;else for(const[r,a]of Object.entries(o))n[`form.${t}.${r}`]=a;e.state.setLocal(n,{render:!1,selfManaged:!0})}function R(t){const{container:o,name:e,height:n,readOnly:r,placeholder:a,multilingual:i}=t;if(o.querySelector(`[${T}]`))return;o.innerHTML="";const s=i?t.locales??[]:[],k=t.activeLocale??s[0]??"",l=i?{...t.contentMap??{}}:{"":t.initialContent??""},c=document.createElement("textarea");c.setAttribute(T,"1"),c.name=e,c.readOnly=r,c.placeholder=a??"",c.style.cssText=["width:100%",`height:${n}px`,"padding:12px","border:1px solid #d1d5db","border-radius:6px","font-family:inherit","font-size:14px","line-height:1.6","resize:vertical","box-sizing:border-box"].join(";");let b=k;if(i){const d=document.createElement("div");d.className="ckeditor5-locale-tabs";const u=()=>{d.querySelectorAll("button").forEach(f=>{const m=f.dataset.locale??"",w=m===b;f.classList.toggle("is-active",w);const S=(l[m]??"").trim()!=="",v=f.querySelector("[data-check]");v&&(v.style.display=S?"":"none")})};s.forEach((f,m)=>{const w=document.createElement("button");w.type="button",w.dataset.locale=f,m===0&&w.classList.add("is-default"),w.textContent=f.toUpperCase();const S=document.createElement("span");S.dataset.check="1",S.textContent="✓",S.style.display="none",w.appendChild(S),w.addEventListener("click",()=>{l[b]=c.value,b=f,c.value=l[f]??"",c.dataset.locale=f,j(e,l),u()}),d.appendChild(w)}),o.appendChild(d),c.dataset.locale=b,c.value=l[b]??"",u(),c.addEventListener("input",()=>{l[b]=c.value,j(e,l),u()})}else c.value=l[""]??"",c.addEventListener("input",()=>{l[""]=c.value,j(e,c.value)});o.appendChild(c),W.set(o,{multilingual:i,values:l,activeLocale:b}),j(e,i?l:l[""]??"")}function $t(t){const o=W.get(t);if(!o)return null;const e=t.querySelector(`textarea[${T}]`);return e&&(o.values[o.multilingual?e.dataset.locale??o.activeLocale:""]=e.value),o.multilingual?{...o.values}:o.values[""]??""}function It(t){return t.querySelector(`[${T}]`)?(t.innerHTML="",W.delete(t),!0):!1}const rt="ckeditor5-dark-override",at="ckeditor5-tab-style",lt={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function Gt(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;if(document.getElementById(o))return Promise.resolve();const e=window?.G7Core?.asset?.loadScript;return new Promise(n=>{let r;try{r=Lt(t)}catch(i){console.warn("[ckeditor5] 번역 URL 을 만들지 못했습니다 (영어로 동작):",i),n();return}if(typeof e=="function"){e(r,{id:o},{label:`ckeditor5 translations: ${t}`,retries:1}).catch(i=>{console.warn(`[ckeditor5] 번역 로드 실패 (${t}) — 영어로 동작합니다:`,i)}).then(()=>n());return}const a=document.createElement("script");a.id=o,a.src=r,a.onload=()=>n(),a.onerror=()=>{console.warn(`[ckeditor5] 번역 로드 실패 (${t}) — 영어로 동작합니다.`),n()},document.head.appendChild(a)})}async function ct(){if(document.getElementById(q))return;const t=window?.G7Core?.asset?.loadStylesheet;let o;try{o=nt()}catch(e){console.warn("[ckeditor5] CSS URL 을 만들지 못했습니다:",e);return}if(typeof t!="function"){const e=document.createElement("link");e.id=q,e.rel="stylesheet",e.href=o,document.head.appendChild(e);return}try{await t(o,{id:q},{label:"ckeditor5 CSS"})}catch(e){console.warn("[ckeditor5] CSS 로드 실패:",e),N("ckeditor5-css",x("editor.asset.style_label","편집기 스타일"),()=>ct())}}async function it(){if(window.CKEDITOR)return!0;const t=window?.G7Core?.asset?.loadScript;if(typeof t!="function")return!1;try{await t(xt(),{id:"ckeditor5-vendor-umd"},{label:"ckeditor5 UMD"})}catch(o){return console.warn("[ckeditor5] 편집기 본체를 불러오지 못했습니다:",o),!1}return!!window.CKEDITOR}async function Q(t,o,e,n,r){const a=$t(e);if(!await it())throw new Error("[sirsoft-ckeditor5] 편집기 본체를 여전히 불러올 수 없습니다.");It(e);const i={...t,params:{...t.params??{},content:n?a??{}:a??""}};if(await mt(i,o),!r)return;window.G7Core?.state?.setLocal?.({[`form.${r}_mode`]:"html"})}function N(t,o,e,n){const r=window?.G7Core?.assets?.notifyFailure;if(typeof r=="function"){r({id:t,label:o,retry:e,message:n});return}console.warn(`[ckeditor5] 자산 로드 실패: ${o}`)}function x(t,o){const e=window?.G7Core?.t;if(typeof e!="function")return o;const n=`sirsoft-ckeditor5.${t}`,r=e(n);return typeof r=="string"&&r!==n?r:o}function Mt(){if(document.getElementById(at))return;const t=document.createElement("style");t.id=at,t.textContent=`
+var SirsoftCkeditor5=function(F){"use strict";const E=new Map;let K=!1;function St(){return K}function tt(t){K=t}const T=new Map;function et(t,o){const e=T.get(t)??[];e.push(o),T.set(t,e)}function ot(t){(T.get(t)??[]).forEach(o=>{try{o.stop()}catch{}}),T.delete(t)}const vt=400,Et=1e4;function nt(t,o){const{name:e,locale:r,multilingual:n}=o;let a=location.pathname,c=null,d=!1,k=null,i=0;const l=()=>{const f=window.G7Core?.state?.getLocal?.()?.form;if(!f||!e)return;const g=n?f[e]?.[r]:f[e];return typeof g=="string"?g:void 0},s=window.setInterval(()=>{let u;try{u=t.getData()}catch{window.clearInterval(s);return}location.pathname!==a&&(a=location.pathname,c=null,d=!0,i=Date.now(),k=u);const f=l();if(f!==void 0){if(d){if(f===k){Date.now()-i>Et&&(d=!1,k=null);return}if(f!==u){tt(!0);try{t.setData(f)}finally{tt(!1)}}d=!1,k=null,c=f;return}if(f===c||f===u){c=f;return}}},vt);return{shouldEmit:()=>!d,noteEmitted:u=>{c=u},stop:()=>window.clearInterval(s)}}const xt="43.3.1",H="sirsoft-ckeditor5",q=`dist/vendor/ckeditor5/${xt}`,V="ckeditor5-vendor-css";function W(){const t=window?.G7Core?.asset;if(!t||typeof t.plugin!="function")throw new Error("G7Core.asset is unavailable — core 7.0.10 or later is required to resolve bundled CKEditor5 assets");return t}function Lt(){return W().plugin(H,`${q}/ckeditor5.umd.js`)}function rt(){return W().plugin(H,`${q}/ckeditor5.css`)}function $t(t){return W().plugin(H,`${q}/translations/${t}.umd.js`)}const j="data-ckeditor5-fallback",R=new WeakMap;function N(t,o,e,r){const n=window.G7Core;if(!n?.state?.setLocal||!t)return;e&&r&&n.state.getLocal?.()?.hasChanges!==!0&&n.state.setLocal({hasChanges:!0});const a={[`form.${t}_mode`]:"text"};if(typeof o=="string")a[`form.${t}`]=o;else for(const[c,d]of Object.entries(o))a[`form.${t}.${c}`]=d;n.state.setLocal(a,{render:!1,selfManaged:!0})}function Q(t){const{container:o,name:e,height:r,readOnly:n,placeholder:a,multilingual:c}=t;if(o.querySelector(`[${j}]`))return;o.innerHTML="";const d=t.trackChanges!==!1,k=c?t.locales??[]:[],i=t.activeLocale??k[0]??"",l=c?{...t.contentMap??{}}:{"":t.initialContent??""},s=document.createElement("textarea");s.setAttribute(j,"1"),s.name=e,s.readOnly=n,s.placeholder=a??"",s.style.cssText=["width:100%",`height:${r}px`,"padding:12px","border:1px solid #d1d5db","border-radius:6px","font-family:inherit","font-size:14px","line-height:1.6","resize:vertical","box-sizing:border-box"].join(";");let u=i;if(c){const f=document.createElement("div");f.className="ckeditor5-locale-tabs";const g=()=>{f.querySelectorAll("button").forEach(b=>{const G=b.dataset.locale??"",m=G===u;b.classList.toggle("is-active",m);const S=(l[G]??"").trim()!=="",v=b.querySelector("[data-check]");v&&(v.style.display=S?"":"none")})};k.forEach((b,G)=>{const m=document.createElement("button");m.type="button",m.dataset.locale=b,G===0&&m.classList.add("is-default"),m.textContent=b.toUpperCase();const S=document.createElement("span");S.dataset.check="1",S.textContent="✓",S.style.display="none",m.appendChild(S),m.addEventListener("click",()=>{l[u]=s.value,u=b,s.value=l[b]??"",s.dataset.locale=b,N(e,l,!1,d),g()}),f.appendChild(m)}),o.appendChild(f),s.dataset.locale=u,s.value=l[u]??"",g(),s.addEventListener("input",()=>{l[u]=s.value,N(e,l,!0,d),g()})}else s.value=l[""]??"",s.addEventListener("input",()=>{l[""]=s.value,N(e,s.value,!0,d)});o.appendChild(s),R.set(o,{multilingual:c,values:l,activeLocale:u}),N(e,c?l:l[""]??"",!1,d)}function It(t){const o=R.get(t);if(!o)return null;const e=t.querySelector(`textarea[${j}]`);return e&&(o.values[o.multilingual?e.dataset.locale??o.activeLocale:""]=e.value),o.multilingual?{...o.values}:o.values[""]??""}function Gt(t){return t.querySelector(`[${j}]`)?(t.innerHTML="",R.delete(t),!0):!1}const at="ckeditor5-dark-override",ct="ckeditor5-tab-style",lt={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function Mt(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;if(document.getElementById(o))return Promise.resolve();const e=window?.G7Core?.asset?.loadScript;return new Promise(r=>{let n;try{n=$t(t)}catch(c){console.warn("[ckeditor5] 번역 URL 을 만들지 못했습니다 (영어로 동작):",c),r();return}if(typeof e=="function"){e(n,{id:o},{label:`ckeditor5 translations: ${t}`,retries:1}).catch(c=>{console.warn(`[ckeditor5] 번역 로드 실패 (${t}) — 영어로 동작합니다:`,c)}).then(()=>r());return}const a=document.createElement("script");a.id=o,a.src=n,a.onload=()=>r(),a.onerror=()=>{console.warn(`[ckeditor5] 번역 로드 실패 (${t}) — 영어로 동작합니다.`),r()},document.head.appendChild(a)})}async function it(){if(document.getElementById(V))return;const t=window?.G7Core?.asset?.loadStylesheet;let o;try{o=rt()}catch(e){console.warn("[ckeditor5] CSS URL 을 만들지 못했습니다:",e);return}if(typeof t!="function"){const e=document.createElement("link");e.id=V,e.rel="stylesheet",e.href=o,document.head.appendChild(e);return}try{await t(o,{id:V},{label:"ckeditor5 CSS"})}catch(e){console.warn("[ckeditor5] CSS 로드 실패:",e),D("ckeditor5-css",x("editor.asset.style_label","편집기 스타일"),()=>it())}}async function st(){if(window.CKEDITOR)return!0;const t=window?.G7Core?.asset?.loadScript;if(typeof t!="function")return!1;try{await t(Lt(),{id:"ckeditor5-vendor-umd"},{label:"ckeditor5 UMD"})}catch(o){return console.warn("[ckeditor5] 편집기 본체를 불러오지 못했습니다:",o),!1}return!!window.CKEDITOR}async function J(t,o,e,r,n){const a=It(e);if(!await st())throw new Error("[sirsoft-ckeditor5] 편집기 본체를 여전히 불러올 수 없습니다.");Gt(e);const c={...t,params:{...t.params??{},content:r?a??{}:a??""}};if(await mt(c,o),!n)return;window.G7Core?.state?.setLocal?.({[`form.${n}_mode`]:"html"})}function D(t,o,e,r){const n=window?.G7Core?.assets?.notifyFailure;if(typeof n=="function"){n({id:t,label:o,retry:e,message:r});return}console.warn(`[ckeditor5] 자산 로드 실패: ${o}`)}function x(t,o){const e=window?.G7Core?.t;if(typeof e!="function")return o;const r=`sirsoft-ckeditor5.${t}`,n=e(r);return typeof n=="string"&&n!==r?n:o}function zt(){if(document.getElementById(ct))return;const t=document.createElement("style");t.id=ct,t.textContent=`
/* 다국어 탭 컨테이너 */
.ckeditor5-locale-tabs {
display: flex;
@@ -149,7 +149,7 @@ var SirsoftCkeditor5=function(D){"use strict";const E=new Map;let Z=!1;function
list-style-type: lower-roman;
padding-left: 2em;
}
- `,document.head.appendChild(t)}function zt(){return document.documentElement.classList.contains("dark")}function st(){const t=document.getElementById(rt);if(!zt()){t&&t.remove();return}if(t)return;const o=document.createElement("style");o.id=rt,o.textContent=`
+ `,document.head.appendChild(t)}function Ut(){return document.documentElement.classList.contains("dark")}function dt(){const t=document.getElementById(at);if(!Ut()){t&&t.remove();return}if(t)return;const o=document.createElement("style");o.id=at,o.textContent=`
/* CKEditor5 다크 모드 오버라이드 - CSS 변수 전역 적용 */
:root {
--ck-color-base-foreground: #1f2937;
@@ -332,7 +332,7 @@ var SirsoftCkeditor5=function(D){"use strict";const E=new Map;let Z=!1;function
.ck.ck-editor__editable li::marker {
color: #d1d5db;
}
- `,document.head.appendChild(o)}let I=null;function Ut(){I||(I=new MutationObserver(()=>{st()}),I.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}))}function At(){I&&(I.disconnect(),I=null)}const Pt={ko:"한국어",en:"English",ja:"日本語",zh:"中文",fr:"Français",de:"Deutsch",es:"Español"};function Bt(t){return Pt[t]??t.toUpperCase()}function dt(){const t=window.G7Core;return t?.locale?.supported?t.locale.supported():[localStorage.getItem("g7_locale")||"ko"]}function L(){return window.G7Core?.locale?.current?.()??localStorage.getItem("g7_locale")??"ko"}function Ot(t,o,e){const n=[t.Essentials,t.Bold,t.Italic,t.Underline,t.Strikethrough,t.Link,t.Paragraph,t.Heading,t.BlockQuote,t.List,t.Alignment,t.Table,t.TableToolbar,t.TableProperties,t.TableCellProperties,t.Indent,t.IndentBlock,t.PasteFromOffice,t.GeneralHtmlSupport,t.Image,t.ImageResize,t.ImageStyle,t.ImageCaption,t.ImageToolbar];return o==="full"&&n.push(t.MediaEmbed,t.HorizontalLine,t.CodeBlock,t.SourceEditing,t.FontSize,t.FontColor,t.FontBackgroundColor),e&&n.push(t.ImageUpload,t.SimpleUploadAdapter),n}function Tt(){return"/api/plugins/sirsoft-ckeditor5/upload"}function jt(){const t=localStorage.getItem("auth_token");return t?{Authorization:`Bearer ${t}`}:{}}async function ut(t,o){const e=window.CKEDITOR;if(!e)throw new Error("[sirsoft-ckeditor5] CKEDITOR 전역 객체를 찾을 수 없습니다. CDN 스크립트가 로드되었는지 확인하세요.");const n=lt[o.toolbar]||lt.standard,r=o.imageUpload?n:n.filter(l=>l!=="uploadImage"),a={initialData:o.initialContent,plugins:Ot(e,o.toolbar,o.imageUpload),toolbar:{items:r},table:{contentToolbar:["tableColumn","tableRow","mergeTableCells","|","tableProperties","tableCellProperties"],tableProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",alignment:"left"}},tableCellProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",padding:"8px"}}},image:{toolbar:["imageStyle:inline","imageStyle:alignLeft","imageStyle:alignCenter","imageStyle:alignRight","|","toggleImageCaption","|","resizeImage"],resizeOptions:[{name:"resizeImage:original",label:"Original",value:null},{name:"resizeImage:25",label:"25%",value:"25"},{name:"resizeImage:50",label:"50%",value:"50"},{name:"resizeImage:75",label:"75%",value:"75"}],resizeUnit:"%"},indentBlock:{offset:40,unit:"px"},link:{defaultProtocol:"https://",decorators:{openInNewTab:{mode:"automatic",callback:l=>l.startsWith("http://")||l.startsWith("https://"),attributes:{target:"_blank",rel:"noopener noreferrer"}}}},htmlSupport:{allow:[{name:/.*/,attributes:!0,classes:!0,styles:!0}]},placeholder:o.placeholder,language:L()};o.imageUpload&&(a.simpleUpload={uploadUrl:Tt(),headers:jt()});const i=await e.ClassicEditor.create(t,a),s=`ckeditor5-height-style-${o.containerId}`,k=document.getElementById(s);if(k)k.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`;else{const l=document.createElement("style");l.id=s,l.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`,document.head.appendChild(l)}return o.readOnly&&i.enableReadOnlyMode("read-only"),i}function Nt(t,o,e,n,r){const a=document.createElement("div");a.className="ckeditor5-locale-tabs";const i=new Map;o.forEach(l=>{const c=document.createElement("div");c.dataset.locale=l,l!==e&&(c.style.cssText="display:none;");const b=document.createElement("div");c.appendChild(b),i.set(l,b)});const s=()=>{r&&a.querySelectorAll("button").forEach(l=>{const c=l.dataset.locale;if(!c||l.classList.contains("is-active")){const f=l.querySelector(".ck5-check");f&&f.remove();return}const d=!!r(c)?.trim(),u=l.querySelector(".ck5-check");if(d&&!u){const f=document.createElement("i");f.className="fas fa-check ck5-check",l.appendChild(f)}else!d&&u&&u.remove()})},k=o[0];return o.forEach(l=>{const c=l===k,b=l===e,d=document.createElement("button");d.type="button",d.dataset.locale=l,d.className=[b?"is-active":"",c?"is-default":""].filter(Boolean).join(" "),d.innerHTML=`${Bt(l)}${c?'*':""}`,d.addEventListener("click",async()=>{a.querySelectorAll("button").forEach(u=>{const m=u.dataset.locale===l;u.classList.toggle("is-active",m)}),i.forEach((u,f)=>{const m=u.parentElement;m&&(m.style.display=f===l?"block":"none")}),n&&await n(l),s()}),a.appendChild(d)}),t.appendChild(a),i.forEach(l=>{l.parentElement&&t.appendChild(l.parentElement)}),{tabsEl:a,contentEls:i,updateCheckIcons:s}}function ft(t,o,e,n){if(_t())return;const r=window.G7Core;if(!r?.state?.setLocal)return;r.state.getLocal?.()?.hasChanges!==!0&&r.state.setLocal({hasChanges:!0});const a={[`form.${t}_mode`]:"html"};n?a[`form.${t}.${o}`]=e:a[`form.${t}`]=e,r.state.setLocal(a,{debounce:300,debounceKey:`ckeditor-sync-${t}`,render:!1,selfManaged:!0})}function kt(t,o){const e=window.G7Core,n=typeof t.content=="string"?t.content:"";let a=n.startsWith("{{")&&n.endsWith("}}")?"":n;if(!a&&o){const s=(e?.state?.getLocal?.()??{})?.form?.[o];typeof s=="string"&&s&&(a=s)}return a}function bt(t,o){const e=window.G7Core;let n={};try{typeof t.content=="string"&&t.content.startsWith("{")&&!t.content.startsWith("{{")?n=JSON.parse(t.content):typeof t.content=="object"&&t.content!==null&&(n=t.content)}catch{n={}}if(Object.keys(n).length===0&&o){const a=(e?.state?.getLocal?.()??{})?.form?.[o];if(a&&typeof a=="object"&&!Array.isArray(a)?n=a:typeof a=="string"&&a&&(n={[L()]:a}),Object.keys(n).length===0){const i=e?.state?.get?.()??{},k=(i?._global?.selectedItem??i?.selectedItem)?.[o];k&&typeof k=="object"&&!Array.isArray(k)&&(n=k)}}return n}async function mt(t,o){const e=t.params??{},r=`ckeditor5-${e.name??"content"}`;et(r);const a=E.get(r);if(a&&a.size>0){const g=[];a.forEach(h=>{h&&typeof h.destroy=="function"&&g.push(h.destroy().catch(()=>{}))}),await Promise.allSettled(g),E.delete(r)}const i=document.getElementById(r);if(!i){console.warn(`[sirsoft-ckeditor5] 컨테이너 엘리먼트를 찾을 수 없습니다: #${r}`);return}ct(),Mt(),st(),Ut(),await Gt(L());const k=window.G7Core?.state?.get()||{},l=k._global?.plugins?.["sirsoft-ckeditor5"]??k.plugins?.["sirsoft-ckeditor5"]??{},c=e.name??"content",b=e.multilingual===!0||e.multilingual==="true",d=e.readOnly===!0||e.readOnly==="true",u=e.imageUpload!==void 0?e.imageUpload===!0||e.imageUpload==="true":l.imageUpload===!0||l.imageUpload==="true",f=e.placeholder??"",m=e.height!==void 0?Number(e.height)||400:Number(l.editorHeight)||400,w=(e.toolbar!==void 0?e.toolbar:l.toolbar)??"standard";let S=b?bt(e,c):{};if(!await it()){const g={container:i,name:c,height:m,readOnly:d,placeholder:f,multilingual:b,locales:b?dt():void 0,activeLocale:L(),contentMap:b?bt(e,c):void 0,initialContent:b?void 0:kt(e,c)};R(g),N(`ckeditor5-editor:${r}`,x("editor.asset.label","편집기"),()=>Q(t,o,i,g.multilingual,c),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."));return}const v=new Map;E.set(r,v);const yt={placeholder:f,readOnly:d,imageUpload:u,height:m,toolbar:w,containerId:r};if(b){const g=dt(),h=L(),C={current:new Map},G={current:()=>{}},M=async p=>{if(v.has(p))return;const P=C.current.get(p);if(!P)return;const Ft=S[p]??"";try{const z=await ut(P,{...yt,initialContent:Ft});v.set(p,z);let Ct=!0;const Y=ot(z,{name:c,locale:p,multilingual:!0});if(tt(r,Y),z.model.document.on("change:data",()=>{if(Ct||!Y.shouldEmit())return;const B=z.getData();Y.noteEmitted(B),ft(c,p,B,!0),G.current()}),Ct=!1,c){const B=window.G7Core;(B?.state?.getLocal?.()??{}).form?.[`${c}_mode`]!=="html"&&B.state.setLocal({[`form.${c}_mode`]:"html"})}}catch(z){console.error(`[sirsoft-ckeditor5] 에디터 초기화 오류 (locale: ${p}):`,z)}},A=p=>{const P=v.get(p);return P?P.getData?.()??null:null},{contentEls:_,updateCheckIcons:wt}=Nt(i,g,h,M,A);if(C.current=_,G.current=wt,await M(h),!v.has(h)){E.delete(r),R({container:i,name:c,height:m,readOnly:d,placeholder:f,multilingual:!0,locales:g,activeLocale:h,contentMap:S}),N(`ckeditor5-editor:${r}`,x("editor.asset.label","편집기"),()=>Q(t,o,i,!0,c),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."));return}setTimeout(()=>{g.filter(p=>p!==h).forEach(p=>{M(p)})},2e3)}else{const g=kt(e,c),h=document.createElement("div");i.appendChild(h);try{const C=await ut(h,{...yt,initialContent:g}),G=L();v.set(G,C);let M=!0;const A=ot(C,{name:c,locale:G,multilingual:!1});if(tt(r,A),C.model.document.on("change:data",()=>{if(M||!A.shouldEmit())return;const _=C.getData();A.noteEmitted(_),ft(c,G,_,!1)}),M=!1,g){const _=C.getData();(!_||_.trim()==="")&&C.setData(g)}if(c){const _=window.G7Core;(_?.state?.getLocal?.()??{}).form?.[`${c}_mode`]!=="html"&&_.state.setLocal({[`form.${c}_mode`]:"html"})}}catch(C){console.error("[sirsoft-ckeditor5] 에디터 초기화 오류:",C),E.delete(r),R({container:i,name:c,height:m,readOnly:d,placeholder:f,multilingual:!1,activeLocale:L(),initialContent:g}),N(`ckeditor5-editor:${r}`,x("editor.asset.label","편집기"),()=>Q(t,o,i,!1,c),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."))}}}async function Dt(t,o){const n=`ckeditor5-${t.params?.name??"content"}`;et(n);const r=E.get(n);if(!r||r.size===0)return;const a=[];r.forEach(s=>{s&&typeof s.destroy=="function"&&a.push(s.destroy().catch(k=>{console.warn("[sirsoft-ckeditor5] destroyEditor error:",k)}))}),await Promise.allSettled(a),E.delete(n);const i=document.getElementById(`ckeditor5-height-style-${n}`);i&&i.remove(),E.size===0&&At()}const y="sirsoft-ckeditor5",J="ckeditor5-content-styles",gt="ckeditor5-content-styles-override";function pt(){if(!document.getElementById(J)){const t=window?.G7Core?.asset;try{const o=nt();if(typeof t?.loadStylesheet=="function")t.loadStylesheet(o,{id:J},{label:"ckeditor5 content CSS"}).catch(e=>{console.warn("[ckeditor5] 본문 스타일 로드 실패:",e),window?.G7Core?.assets?.notifyFailure?.({id:"ckeditor5-content-css",label:"본문 스타일",retry:()=>pt()})});else{const e=document.createElement("link");e.id=J,e.rel="stylesheet",e.href=o,document.head.appendChild(e)}}catch(o){console.warn("[ckeditor5] 본문 스타일 URL 을 만들지 못했습니다:",o)}}if(!document.getElementById(gt)){const t=document.createElement("style");t.id=gt,t.textContent=`
+ `,document.head.appendChild(o)}let I=null;function At(){I||(I=new MutationObserver(()=>{dt()}),I.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}))}function Pt(){I&&(I.disconnect(),I=null)}const Bt={ko:"한국어",en:"English",ja:"日本語",zh:"中文",fr:"Français",de:"Deutsch",es:"Español"};function Ot(t){return Bt[t]??t.toUpperCase()}function ut(){const t=window.G7Core;return t?.locale?.supported?t.locale.supported():[localStorage.getItem("g7_locale")||"ko"]}function L(){return window.G7Core?.locale?.current?.()??localStorage.getItem("g7_locale")??"ko"}function Tt(t,o,e){const r=[t.Essentials,t.Bold,t.Italic,t.Underline,t.Strikethrough,t.Link,t.Paragraph,t.Heading,t.BlockQuote,t.List,t.Alignment,t.Table,t.TableToolbar,t.TableProperties,t.TableCellProperties,t.Indent,t.IndentBlock,t.PasteFromOffice,t.GeneralHtmlSupport,t.Image,t.ImageResize,t.ImageStyle,t.ImageCaption,t.ImageToolbar];return o==="full"&&r.push(t.MediaEmbed,t.HorizontalLine,t.CodeBlock,t.SourceEditing,t.FontSize,t.FontColor,t.FontBackgroundColor),e&&r.push(t.ImageUpload,t.SimpleUploadAdapter),r}function jt(){return"/api/plugins/sirsoft-ckeditor5/upload"}function Nt(){const t=localStorage.getItem("auth_token");return t?{Authorization:`Bearer ${t}`}:{}}async function ft(t,o){const e=window.CKEDITOR;if(!e)throw new Error("[sirsoft-ckeditor5] CKEDITOR 전역 객체를 찾을 수 없습니다. CDN 스크립트가 로드되었는지 확인하세요.");const r=lt[o.toolbar]||lt.standard,n=o.imageUpload?r:r.filter(i=>i!=="uploadImage"),a={initialData:o.initialContent,plugins:Tt(e,o.toolbar,o.imageUpload),toolbar:{items:n},table:{contentToolbar:["tableColumn","tableRow","mergeTableCells","|","tableProperties","tableCellProperties"],tableProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",alignment:"left"}},tableCellProperties:{defaultProperties:{borderStyle:"solid",borderColor:"#d1d5db",borderWidth:"1px",padding:"8px"}}},image:{toolbar:["imageStyle:inline","imageStyle:alignLeft","imageStyle:alignCenter","imageStyle:alignRight","|","toggleImageCaption","|","resizeImage"],resizeOptions:[{name:"resizeImage:original",label:"Original",value:null},{name:"resizeImage:25",label:"25%",value:"25"},{name:"resizeImage:50",label:"50%",value:"50"},{name:"resizeImage:75",label:"75%",value:"75"}],resizeUnit:"%"},indentBlock:{offset:40,unit:"px"},link:{defaultProtocol:"https://",decorators:{openInNewTab:{mode:"automatic",callback:i=>i.startsWith("http://")||i.startsWith("https://"),attributes:{target:"_blank",rel:"noopener noreferrer"}}}},htmlSupport:{allow:[{name:/.*/,attributes:!0,classes:!0,styles:!0}]},placeholder:o.placeholder,language:L()};o.imageUpload&&(a.simpleUpload={uploadUrl:jt(),headers:Nt()});const c=await e.ClassicEditor.create(t,a),d=`ckeditor5-height-style-${o.containerId}`,k=document.getElementById(d);if(k)k.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`;else{const i=document.createElement("style");i.id=d,i.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`,document.head.appendChild(i)}return o.readOnly&&c.enableReadOnlyMode("read-only"),c}function Dt(t,o,e,r,n){const a=document.createElement("div");a.className="ckeditor5-locale-tabs";const c=new Map;o.forEach(i=>{const l=document.createElement("div");l.dataset.locale=i,i!==e&&(l.style.cssText="display:none;");const s=document.createElement("div");l.appendChild(s),c.set(i,s)});const d=()=>{n&&a.querySelectorAll("button").forEach(i=>{const l=i.dataset.locale;if(!l||i.classList.contains("is-active")){const g=i.querySelector(".ck5-check");g&&g.remove();return}const u=!!n(l)?.trim(),f=i.querySelector(".ck5-check");if(u&&!f){const g=document.createElement("i");g.className="fas fa-check ck5-check",i.appendChild(g)}else!u&&f&&f.remove()})},k=o[0];return o.forEach(i=>{const l=i===k,s=i===e,u=document.createElement("button");u.type="button",u.dataset.locale=i,u.className=[s?"is-active":"",l?"is-default":""].filter(Boolean).join(" "),u.innerHTML=`${Ot(i)}${l?'*':""}`,u.addEventListener("click",async()=>{a.querySelectorAll("button").forEach(f=>{const b=f.dataset.locale===i;f.classList.toggle("is-active",b)}),c.forEach((f,g)=>{const b=f.parentElement;b&&(b.style.display=g===i?"block":"none")}),r&&await r(i),d()}),a.appendChild(u)}),t.appendChild(a),c.forEach(i=>{i.parentElement&&t.appendChild(i.parentElement)}),{tabsEl:a,contentEls:c,updateCheckIcons:d}}function kt(t,o,e,r,n=!0){if(St())return;const a=window.G7Core;if(!a?.state?.setLocal)return;n&&a.state.getLocal?.()?.hasChanges!==!0&&a.state.setLocal({hasChanges:!0});const c={[`form.${t}_mode`]:"html"};r?c[`form.${t}.${o}`]=e:c[`form.${t}`]=e,a.state.setLocal(c,{debounce:300,debounceKey:`ckeditor-sync-${t}`,render:!1,selfManaged:!0})}function gt(t,o){const e=window.G7Core,r=typeof t.content=="string"?t.content:"";let a=r.startsWith("{{")&&r.endsWith("}}")?"":r;if(!a&&o){const d=(e?.state?.getLocal?.()??{})?.form?.[o];typeof d=="string"&&d&&(a=d)}return a}function bt(t,o){const e=window.G7Core;let r={};try{typeof t.content=="string"&&t.content.startsWith("{")&&!t.content.startsWith("{{")?r=JSON.parse(t.content):typeof t.content=="object"&&t.content!==null&&(r=t.content)}catch{r={}}if(Object.keys(r).length===0&&o){const a=(e?.state?.getLocal?.()??{})?.form?.[o];if(a&&typeof a=="object"&&!Array.isArray(a)?r=a:typeof a=="string"&&a&&(r={[L()]:a}),Object.keys(r).length===0){const c=e?.state?.get?.()??{},k=(c?._global?.selectedItem??c?.selectedItem)?.[o];k&&typeof k=="object"&&!Array.isArray(k)&&(r=k)}}return r}async function mt(t,o){const e=t.params??{},n=`ckeditor5-${e.name??"content"}`;ot(n);const a=E.get(n);if(a&&a.size>0){const p=[];a.forEach(y=>{y&&typeof y.destroy=="function"&&p.push(y.destroy().catch(()=>{}))}),await Promise.allSettled(p),E.delete(n)}const c=document.getElementById(n);if(!c){console.warn(`[sirsoft-ckeditor5] 컨테이너 엘리먼트를 찾을 수 없습니다: #${n}`);return}it(),zt(),dt(),At(),await Mt(L());const k=window.G7Core?.state?.get()||{},i=k._global?.plugins?.["sirsoft-ckeditor5"]??k.plugins?.["sirsoft-ckeditor5"]??{},l=e.name??"content",s=e.multilingual===!0||e.multilingual==="true",u=e.readOnly===!0||e.readOnly==="true",f=e.imageUpload!==void 0?e.imageUpload===!0||e.imageUpload==="true":i.imageUpload===!0||i.imageUpload==="true",g=e.placeholder??"",b=e.height!==void 0?Number(e.height)||400:Number(i.editorHeight)||400,G=(e.toolbar!==void 0?e.toolbar:i.toolbar)??"standard",m=!(e.trackChanges===!1||e.trackChanges==="false");let S=s?bt(e,l):{};if(!await st()){const p={container:c,name:l,height:b,readOnly:u,placeholder:g,trackChanges:m,multilingual:s,locales:s?ut():void 0,activeLocale:L(),contentMap:s?bt(e,l):void 0,initialContent:s?void 0:gt(e,l)};Q(p),D(`ckeditor5-editor:${n}`,x("editor.asset.label","편집기"),()=>J(t,o,c,p.multilingual,l),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."));return}const v=new Map;E.set(n,v);const wt={placeholder:g,readOnly:u,imageUpload:f,height:b,toolbar:G,containerId:n};if(s){const p=ut(),y=L(),C={current:new Map},M={current:()=>{}},z=async h=>{if(v.has(h))return;const B=C.current.get(h);if(!B)return;const Ht=S[h]??"";try{const U=await ft(B,{...wt,initialContent:Ht});v.set(h,U);let _t=!0;const Z=nt(U,{name:l,locale:h,multilingual:!0});if(et(n,Z),U.model.document.on("change:data",()=>{if(_t||!Z.shouldEmit())return;const O=U.getData();Z.noteEmitted(O),kt(l,h,O,!0,m),M.current()}),_t=!1,l){const O=window.G7Core;(O?.state?.getLocal?.()??{}).form?.[`${l}_mode`]!=="html"&&O.state.setLocal({[`form.${l}_mode`]:"html"})}}catch(U){console.error(`[sirsoft-ckeditor5] 에디터 초기화 오류 (locale: ${h}):`,U)}},P=h=>{const B=v.get(h);return B?B.getData?.()??null:null},{contentEls:_,updateCheckIcons:Ct}=Dt(c,p,y,z,P);if(C.current=_,M.current=Ct,await z(y),!v.has(y)){E.delete(n),Q({container:c,name:l,height:b,readOnly:u,placeholder:g,multilingual:!0,locales:p,activeLocale:y,contentMap:S,trackChanges:m}),D(`ckeditor5-editor:${n}`,x("editor.asset.label","편집기"),()=>J(t,o,c,!0,l),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."));return}setTimeout(()=>{p.filter(h=>h!==y).forEach(h=>{z(h)})},2e3)}else{const p=gt(e,l),y=document.createElement("div");c.appendChild(y);try{const C=await ft(y,{...wt,initialContent:p}),M=L();v.set(M,C);let z=!0;const P=nt(C,{name:l,locale:M,multilingual:!1});if(et(n,P),C.model.document.on("change:data",()=>{if(z||!P.shouldEmit())return;const _=C.getData();P.noteEmitted(_),kt(l,M,_,!1,m)}),z=!1,p){const _=C.getData();(!_||_.trim()==="")&&C.setData(p)}if(l){const _=window.G7Core;(_?.state?.getLocal?.()??{}).form?.[`${l}_mode`]!=="html"&&_.state.setLocal({[`form.${l}_mode`]:"html"})}}catch(C){console.error("[sirsoft-ckeditor5] 에디터 초기화 오류:",C),E.delete(n),Q({container:c,name:l,height:b,readOnly:u,placeholder:g,multilingual:!1,activeLocale:L(),initialContent:p,trackChanges:m}),D(`ckeditor5-editor:${n}`,x("editor.asset.label","편집기"),()=>J(t,o,c,!1,l),x("editor.asset.fallback_notice","편집기를 불러오지 못했습니다. 임시 입력창으로 전환했습니다. 작성한 내용은 그대로 저장됩니다."))}}}async function Ft(t,o){const r=`ckeditor5-${t.params?.name??"content"}`;ot(r);const n=E.get(r);if(!n||n.size===0)return;const a=[];n.forEach(d=>{d&&typeof d.destroy=="function"&&a.push(d.destroy().catch(k=>{console.warn("[sirsoft-ckeditor5] destroyEditor error:",k)}))}),await Promise.allSettled(a),E.delete(r);const c=document.getElementById(`ckeditor5-height-style-${r}`);c&&c.remove(),E.size===0&&Pt()}const w="sirsoft-ckeditor5",X="ckeditor5-content-styles",pt="ckeditor5-content-styles-override";function ht(){if(!document.getElementById(X)){const t=window?.G7Core?.asset;try{const o=rt();if(typeof t?.loadStylesheet=="function")t.loadStylesheet(o,{id:X},{label:"ckeditor5 content CSS"}).catch(e=>{console.warn("[ckeditor5] 본문 스타일 로드 실패:",e),window?.G7Core?.assets?.notifyFailure?.({id:"ckeditor5-content-css",label:"본문 스타일",retry:()=>ht()})});else{const e=document.createElement("link");e.id=X,e.rel="stylesheet",e.href=o,document.head.appendChild(e)}}catch(o){console.warn("[ckeditor5] 본문 스타일 URL 을 만들지 못했습니다:",o)}}if(!document.getElementById(pt)){const t=document.createElement("style");t.id=pt,t.textContent=`
/* 이미지 캡션 - 다크모드 */
html.dark .ck-content .image > figcaption { color: hsl(0, 0%, 80%); background-color: hsl(0, 0%, 15%); }
@@ -369,4 +369,4 @@ var SirsoftCkeditor5=function(D){"use strict";const E=new Map;let Z=!1;function
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
- `,document.head.appendChild(t)}}const $={initEditor:mt,destroyEditor:Dt,injectContentCss:pt},U=window.G7Core?.createLogger?.(`Plugin:${y}`)??{log:(...t)=>console.log(`[Plugin:${y}]`,...t),warn:(...t)=>console.warn(`[Plugin:${y}]`,...t),error:(...t)=>console.error(`[Plugin:${y}]`,...t)};function ht(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries($).forEach(([e,n])=>{const r=`${y}.${e}`;o.registerHandler(r,n,{category:"plugin",source:y})}),U.log(`${Object.keys($).length} handler(s) registered:`,Object.keys($).map(e=>`${y}.${e}`));else if(t){let e=0;const n=50,r=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries($).forEach(([i,s])=>{const k=`${y}.${i}`;a.registerHandler(k,s,{category:"plugin",source:y})}),U.log(`${Object.keys($).length} handler(s) registered:`,Object.keys($).map(i=>`${y}.${i}`))):(e++,e<=n?(U.warn(`ActionDispatcher not found, retrying... (${e}/${n})`),setTimeout(r,100)):U.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};r()}else U.warn("ActionDispatcher not found, handlers not registered")}function X(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>ht(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();ht(!t)}}return X(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:y,handlers:Object.keys($),initPlugin:X}),D.initPlugin=X,Object.defineProperty(D,Symbol.toStringTag,{value:"Module"}),D}({});
+ `,document.head.appendChild(t)}}const $={initEditor:mt,destroyEditor:Ft,injectContentCss:ht},A=window.G7Core?.createLogger?.(`Plugin:${w}`)??{log:(...t)=>console.log(`[Plugin:${w}]`,...t),warn:(...t)=>console.warn(`[Plugin:${w}]`,...t),error:(...t)=>console.error(`[Plugin:${w}]`,...t)};function yt(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries($).forEach(([e,r])=>{const n=`${w}.${e}`;o.registerHandler(n,r,{category:"plugin",source:w})}),A.log(`${Object.keys($).length} handler(s) registered:`,Object.keys($).map(e=>`${w}.${e}`));else if(t){let e=0;const r=50,n=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries($).forEach(([c,d])=>{const k=`${w}.${c}`;a.registerHandler(k,d,{category:"plugin",source:w})}),A.log(`${Object.keys($).length} handler(s) registered:`,Object.keys($).map(c=>`${w}.${c}`))):(e++,e<=r?(A.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(n,100)):A.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};n()}else A.warn("ActionDispatcher not found, handlers not registered")}function Y(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>yt(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();yt(!t)}}return Y(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:w,handlers:Object.keys($),initPlugin:Y}),F.initPlugin=Y,Object.defineProperty(F,Symbol.toStringTag,{value:"Module"}),F}({});
diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md b/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md
index 142900c8..b4032140 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md
+++ b/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md
@@ -81,6 +81,20 @@ _훅 리스너가 없습니다._
| `html-editor.json` | `html_editor` | 동봉 CKEditor 5 UMD 를 로드하고 컨테이너 `onMount` 에서 `initEditor` 실행 |
| `html-content.json` | `html_content` | 저장된 본문을 읽기 화면에 렌더 |
+`html_editor` 를 쓰는 쪽은 `props` 로 `name` · `content` · `multilingual` · `placeholder` ·
+`readOnly` 를 선언합니다. 여기에 **`trackChanges`(기본 `true`)** 가 있습니다 — 이 편집기의 입력을
+"폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부입니다.
+
+저장 대상이 아닌 편집기(설정 화면의 미리보기처럼 시험용으로 두는 자리)는 `false` 로 선언합니다.
+그러지 않으면 운영자가 시험 삼아 글자만 쳐도 [저장] 버튼이 켜져, 바뀐 것이 없는데 바뀐 것처럼
+보입니다. 어느 편집기가 저장 대상인지는 **레이아웃만 압니다** — 그래서 핸들러가 필드명으로
+알아보지 않고 선언으로 받습니다. 필드명을 알아보게 만들면 다른 확장이 같은 미리보기 패턴을
+쓸 때 그대로 재발합니다.
+
+저장 대상이 아닌 편집기는 **저장 요청 body 에서도 그 필드를 빼야** 합니다. 자동바인딩은
+`trackChanges` 와 무관하게 값을 `_local.form` 에 쌓으므로, body 를 `_local.form` 통째로 보내면
+미리보기 입력이 설정으로 저장됩니다. 서버는 200 을 돌려주므로 화면에는 아무 이상이 없습니다.
+
둘 다 `mode: replace` 입니다 — 확장점 자리를 비우고 대신 들어갑니다. 같은 확장점을 노리는
다른 편집기 플러그인이 함께 활성화되면 어느 쪽이 이기는지가 설치 순서에 좌우되므로, 편집기
플러그인은 하나만 켭니다.
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/extensions/html-editor.json b/plugins/_bundled/sirsoft-ckeditor5/resources/extensions/html-editor.json
index e9c1b656..47a33f5a 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/extensions/html-editor.json
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/extensions/html-editor.json
@@ -28,7 +28,8 @@
"readOnly": "{{_computed.isReadOnly ?? extensionPointProps.disabled ?? extensionPointProps.readOnly ?? false}}",
"imageUpload": "{{_global.plugins['sirsoft-ckeditor5']?.imageUpload ?? false}}",
"height": "{{_global.plugins['sirsoft-ckeditor5']?.editorHeight ?? 400}}",
- "toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}"
+ "toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}",
+ "trackChanges": "{{extensionPointProps.trackChanges ?? true}}"
}
}
],
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/html-editor-fallback.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/html-editor-fallback.test.tsx
index 33b970af..49a27b9e 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/html-editor-fallback.test.tsx
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/html-editor-fallback.test.tsx
@@ -88,7 +88,15 @@ describe('CKEditor5 textarea 폴백', () => {
expect(updates['form.content']).toBe('사용자가 쓴 글');
expect(updates['form.content_mode']).toBe('text');
- expect(updates.hasChanges).toBe(true);
+
+ // `hasChanges` 는 이 배치에 없다 — 배치가 `render:false + selfManaged:true` 라
+ // React 렌더를 일으키지 않아, 여기에 실으면 저장 버튼의 활성 조건이 재평가되지
+ // 않는다(수정 화면에서 저장 자체가 불가능해진다). 별도 setLocal 로 나간다.
+ expect(updates.hasChanges, '본문 배치에 섞으면 저장 버튼이 안 켜진다').toBeUndefined();
+ expect(
+ setLocal.mock.calls.some(([u]: [Record]) => u.hasChanges === true),
+ '플래그 자체는 올라가야 한다',
+ ).toBe(true);
});
/**
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/initEditor-has-changes-render.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/initEditor-has-changes-render.test.tsx
index 9f35707b..9858aebf 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/initEditor-has-changes-render.test.tsx
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/initEditor-has-changes-render.test.tsx
@@ -18,6 +18,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { syncToForm } from '../../handlers/initEditor';
+import { renderTextareaFallback } from '../../handlers/textareaFallback';
type Call = { updates: Record; options?: Record };
@@ -119,3 +120,201 @@ describe('편집기 본문 동기화 — hasChanges 렌더 분리 (저장 버튼
expect(hasChangesCalls()).toHaveLength(1);
});
});
+
+/**
+ * 저장 대상이 아닌 편집기는 폼을 "변경됨" 으로 만들지 않는다.
+ *
+ * 위 수정이 `hasChanges` 를 **렌더를 일으키는** setLocal 로 승격시키면서, 설정 화면의
+ * 미리보기 편집기처럼 저장 대상이 아닌 편집기의 입력까지 [저장] 버튼을 즉시 켜게 됐다.
+ * 미리보기 내용은 저장 액션의 body 에서 제외되므로 잘못 저장될 위험은 없지만, 운영자에게는
+ * "바뀐 것이 없는데 바뀐 것처럼" 보인다.
+ *
+ * 어느 편집기가 저장 대상인지는 **레이아웃이 안다**. 그래서 핸들러가 필드명(`preview_content`)
+ * 을 알아보는 대신 `trackChanges` 선언으로 받는다 — 필드명 하드코딩은 다른 확장이 같은
+ * 미리보기 패턴을 쓸 때 그대로 재발한다.
+ */
+describe('편집기 본문 동기화 — 저장 대상이 아닌 편집기 (trackChanges:false)', () => {
+ beforeEach(() => {
+ stubG7Core();
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ delete (window as any).G7Core;
+ });
+
+ it('trackChanges:false 면 hasChanges 를 아예 보내지 않는다', () => {
+ syncToForm('preview_content', 'ko', '시험 입력
', true, false);
+
+ expect(
+ hasChangesCalls(),
+ '미리보기 입력이 [저장] 버튼을 켜면 안 된다',
+ ).toHaveLength(0);
+ });
+
+ it('trackChanges:false 여도 본문 동기화는 그대로 한다 (미리보기 렌더가 이 값을 읽는다)', () => {
+ syncToForm('preview_content', 'ko', '시험 입력
', true, false);
+
+ const body = contentCalls();
+ expect(body, '본문은 종전대로 보낸다').toHaveLength(1);
+ expect(body[0].updates['form.preview_content.ko']).toBe('시험 입력
');
+ expect(body[0].updates['form.preview_content_mode']).toBe('html');
+ expect(body[0].options?.render).toBe(false);
+ expect(body[0].options?.selfManaged).toBe(true);
+ });
+
+ it('생략하면 종전대로 hasChanges 를 보낸다 (기본값 true — 게시글 편집기 보호)', () => {
+ syncToForm('content', 'ko', '본문
', false);
+
+ expect(hasChangesCalls(), '기본값이 false 로 뒤집히면 저장 버튼 회귀가 되돌아온다').toHaveLength(1);
+ });
+
+ it('trackChanges:false 인 편집기는 이미 켜진 hasChanges 를 끄지도 않는다', () => {
+ // 다른 입력이 이미 폼을 변경 상태로 만든 뒤 미리보기를 건드리는 순서
+ stubG7Core({ hasChanges: true });
+
+ syncToForm('preview_content', 'ko', '시험
', true, false);
+
+ expect(hasChangesCalls(), '건드리지 않는다 — 끄면 진짜 변경이 묻힌다').toHaveLength(0);
+ expect(localState.hasChanges, '기존 변경 상태는 유지돼야 한다').toBe(true);
+ });
+});
+
+/**
+ * 회귀: 평문 폴백에서도 본문만 고치면 저장 버튼이 활성화된다 (형제 경로 패리티)
+ *
+ * `syncToForm` 의 결함을 고치면서 **형제 경로인 `syncFallbackToForm` 은 그대로 남았다.**
+ * 그쪽도 `hasChanges: true` 를 `render:false + selfManaged:true` 배치에 넣으므로,
+ * 편집기 자산을 못 불러와 평문 입력창으로 내려간 상태에서 본문만 고치면
+ * `admin_board_post_form.json` 의 저장 버튼
+ * (`disabled: "{{... || (!!route?.id && !_local.hasChanges)}}"`)이 계속 비활성이다 —
+ * **글 수정 자체가 불가능하다.** 폐쇄망·방화벽·광고차단기 환경에서는 그 폴백이 정상 경로다.
+ *
+ * 폴백에는 편집기에 없는 결이 하나 더 있다: `syncFallbackToForm` 은 사용자 입력뿐 아니라
+ * **렌더 시점에도** 불린다(`_mode='text'` 를 미리 심어 두려고). 그 자리까지 플래그를 켜면
+ * 사용자가 아무것도 입력하지 않았는데 화면을 여는 것만으로 "변경됨" 이 된다. 그래서
+ * 사용자 입력에서 온 호출만 플래그를 올린다.
+ */
+describe('평문 폴백 본문 동기화 — hasChanges 렌더 분리 (형제 경로)', () => {
+ let container: HTMLElement;
+
+ /**
+ * 폴백을 세울 컨테이너를 만듭니다.
+ *
+ * @return 문서에 붙은 빈 컨테이너
+ */
+ function mountContainer(): HTMLElement {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ return el;
+ }
+
+ beforeEach(() => {
+ stubG7Core();
+ container = mountContainer();
+ });
+
+ afterEach(() => {
+ container.remove();
+ delete (window as any).G7Core;
+ });
+
+ it('폴백이 서는 것만으로는 폼이 변경됨이 되지 않는다', () => {
+ renderTextareaFallback({
+ container,
+ name: 'content',
+ height: 400,
+ readOnly: false,
+ multilingual: false,
+ initialContent: '서버 원본
',
+ });
+
+ expect(
+ hasChangesCalls(),
+ '입력이 없는데 [저장] 이 켜지면 운영자가 바뀐 줄 알고 누른다',
+ ).toHaveLength(0);
+ // 평문 계약은 그대로 심어야 한다 (서버가 HTML 로 신뢰하면 안 된다)
+ expect(contentCalls().at(-1)?.updates['form.content_mode']).toBe('text');
+ });
+
+ it('폴백 입력창에 글자를 치면 hasChanges 가 렌더를 일으키며 올라간다', () => {
+ renderTextareaFallback({
+ container,
+ name: 'content',
+ height: 400,
+ readOnly: false,
+ multilingual: false,
+ initialContent: '',
+ });
+
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ textarea.value = '폴백으로 쓴 본문';
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ const flag = hasChangesCalls();
+ expect(flag, '입력했는데 플래그가 안 오르면 저장 버튼이 비활성으로 남는다').toHaveLength(1);
+ expect(flag[0].updates.hasChanges).toBe(true);
+ expect(flag[0].options?.render, 'render:false 면 React 가 버튼을 다시 그리지 않는다').not.toBe(false);
+ expect(flag[0].options?.selfManaged, 'selfManaged 면 안 된다').not.toBe(true);
+ });
+
+ it('본문 배치에는 hasChanges 가 섞이지 않는다', () => {
+ renderTextareaFallback({
+ container,
+ name: 'content',
+ height: 400,
+ readOnly: false,
+ multilingual: false,
+ initialContent: '',
+ });
+
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ textarea.value = 'x';
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ for (const c of contentCalls()) {
+ expect('hasChanges' in c.updates, 'render:false 배치에 섞이면 저장소 A 가 못 받는다').toBe(false);
+ }
+ });
+
+ it('연속 입력에서도 hasChanges 렌더는 첫 1회뿐이다', () => {
+ renderTextareaFallback({
+ container,
+ name: 'content',
+ height: 400,
+ readOnly: false,
+ multilingual: false,
+ initialContent: '',
+ });
+
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ for (const v of ['a', 'ab', 'abc']) {
+ textarea.value = v;
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+
+ expect(hasChangesCalls(), '편집 세션당 추가 렌더는 최대 1회').toHaveLength(1);
+ });
+
+ it('저장 대상이 아닌 편집기의 폴백은 폼을 변경됨으로 만들지 않는다', () => {
+ renderTextareaFallback({
+ container,
+ name: 'preview_content',
+ height: 400,
+ readOnly: false,
+ multilingual: false,
+ initialContent: '',
+ trackChanges: false,
+ });
+
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ textarea.value = '미리보기 시험';
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+
+ expect(
+ hasChangesCalls(),
+ '미리보기가 폴백으로 내려앉아도 저장 대상이 아닌 것은 그대로다',
+ ).toHaveLength(0);
+ expect(contentCalls().at(-1)?.updates['form.preview_content'], '본문 동기화는 유지').toBe('미리보기 시험');
+ });
+});
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPreviewNotSaved.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPreviewNotSaved.test.tsx
new file mode 100644
index 00000000..94751826
--- /dev/null
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPreviewNotSaved.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * CKEditor5 설정 화면 — 미리보기 편집기는 저장 대상이 아니다
+ *
+ * @description
+ * 설정 화면 하단의 미리보기 편집기는 운영자가 설정을 시험해 보는 자리다. 그런데 그
+ * 편집기도 `props.name` 을 갖기 때문에 폼 자동바인딩으로 `_local.form` 에 쌓인다.
+ * 여기서 두 가지가 조용히 어긋난다:
+ *
+ * ① 저장 body 가 `_local.form` 을 통째로 보내면 미리보기 입력이 설정으로 저장된다.
+ * 서버는 200 을 돌려주므로 화면에는 아무 이상이 없다.
+ * ② 미리보기 입력이 `_local.hasChanges` 를 켜면 [저장] 버튼이 활성화된다.
+ * 바뀐 설정이 없는데 바뀐 것처럼 보인다.
+ *
+ * 둘 다 **선언**으로 막는다 — 핸들러가 필드명(`preview_content`)을 알아보게 하면,
+ * 다른 확장이 같은 미리보기 패턴을 쓸 때 그대로 재발한다.
+ *
+ * 이 계약은 화면에 오류로 드러나지 않으므로(저장은 성공하고 버튼은 눌린다) 선언 자체를
+ * 잠근다. 특히 body 표현식은 평가기가 거부하는 구문(rest 구조분해 / 콜백 안 멤버 대입)을
+ * 쓰면 **원문 문자열이 그대로 전송**되고 200 이 떨어져 드러나지 않는다.
+ *
+ * @effects preview_editor_excluded_from_save_body, preview_editor_does_not_dirty_form
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import pluginSettings from '../../../layouts/admin/plugin_settings.json';
+import htmlEditorExtension from '../../../extensions/html-editor.json';
+
+/**
+ * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다 (slots·default 포함).
+ *
+ * @param root 탐색 시작 노드
+ * @param match 판정 함수
+ * @return 찾은 노드 또는 null
+ */
+function findNode(root: unknown, match: (node: any) => boolean): any | null {
+ let found: any = null;
+
+ const walk = (node: any): void => {
+ if (found || !node || typeof node !== 'object') return;
+ if (!Array.isArray(node) && match(node)) {
+ found = node;
+ return;
+ }
+ for (const value of Object.values(node)) {
+ if (Array.isArray(value)) value.forEach(walk);
+ else if (value && typeof value === 'object') walk(value);
+ }
+ };
+
+ walk(root);
+ return found;
+}
+
+describe('설정 화면 미리보기 편집기 — 저장 대상 제외', () => {
+ it('미리보기 편집기가 trackChanges:false 를 선언한다', () => {
+ const preview = findNode(pluginSettings, (n) => n.id === 'preview_editor');
+
+ expect(preview, 'preview_editor 노드를 찾아야 한다').not.toBeNull();
+ expect(
+ preview.props?.trackChanges,
+ '선언이 빠지면 미리보기 입력만으로 [저장] 이 켜진다',
+ ).toBe(false);
+ });
+
+ it('확장점이 trackChanges 를 핸들러 params 까지 나른다', () => {
+ const container = findNode(htmlEditorExtension, (n) => n.id === 'ckeditor5_container');
+ const onMount = container?.lifecycle?.onMount?.[0];
+
+ expect(onMount?.handler).toBe('sirsoft-ckeditor5.initEditor');
+ expect(
+ onMount?.params?.trackChanges,
+ 'params 로 나르지 않으면 레이아웃 선언이 핸들러에 도달하지 않는다 (선언은 있는데 무효)',
+ ).toBe("{{extensionPointProps.trackChanges ?? true}}");
+ });
+
+ it('선언하지 않은 편집기는 기본값(변경 추적함)으로 남는다', () => {
+ // 확장점 표현식의 기본값이 true 여야 게시글 편집기의 저장 버튼 활성화가 유지된다.
+ const container = findNode(htmlEditorExtension, (n) => n.id === 'ckeditor5_container');
+ const expr = container.lifecycle.onMount[0].params.trackChanges as string;
+
+ expect(expr, '기본값이 false 로 뒤집히면 저장 버튼 회귀가 되돌아온다').toContain('?? true');
+ });
+
+ it('저장 body 가 미리보기 키를 제외한다', () => {
+ const save = findNode(
+ pluginSettings,
+ (n) =>
+ n.handler === 'apiCall'
+ && typeof n.params?.body === 'string'
+ && n.params.body.includes('_local.form'),
+ );
+
+ expect(save, '설정 저장 apiCall 을 찾아야 한다').not.toBeNull();
+
+ const body = save.params.body as string;
+ expect(body, '미리보기 본문을 제외해야 한다').toContain('preview_content');
+ expect(body, '미리보기 모드 플래그도 제외해야 한다').toContain('preview_content_mode');
+
+ // 평가기가 거부하는 구문이 들어가면 식이 죽고 원문 문자열이 그대로 전송된다
+ // (오류 없이 200 이 떨어져 화면으로는 드러나지 않는다).
+ expect(body, 'rest 구조분해 금지').not.toMatch(/\.\.\.\w+\s*\}\s*=/);
+ expect(body, '콜백 안 멤버 대입 금지').not.toMatch(/\w+\[[^\]]+\]\s*=[^=]/);
+ });
+
+ it('저장 body 표현식이 실제로 미리보기 키를 걸러낸다 (죽은 식이면 여기서 잡힌다)', () => {
+ const save = findNode(
+ pluginSettings,
+ (n) =>
+ n.handler === 'apiCall'
+ && typeof n.params?.body === 'string'
+ && n.params.body.includes('_local.form'),
+ );
+
+ // `{{...}}` 를 벗겨 식만 실행한다.
+ const expr = (save.params.body as string).replace(/^\{\{/, '').replace(/\}\}$/, '');
+ // eslint-disable-next-line no-new-func
+ const run = new Function('_local', `return ${expr};`);
+
+ const out = run({
+ form: {
+ editorHeight: 500,
+ toolbar: 'full',
+ preview_content: { ko: '시험
' },
+ preview_content_mode: 'html',
+ },
+ });
+
+ expect(Object.keys(out).sort(), '설정 키만 남아야 한다').toEqual(['editorHeight', 'toolbar']);
+ });
+});
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/initEditor.ts b/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/initEditor.ts
index 3439a78f..a0b8e829 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/initEditor.ts
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/initEditor.ts
@@ -74,6 +74,15 @@ interface InitEditorParams {
imageUpload?: boolean | string;
height?: number | string;
toolbar?: string;
+ /**
+ * 이 편집기의 입력을 "폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부. 기본 `true`.
+ *
+ * 저장 대상이 아닌 편집기(설정 화면의 미리보기 등)는 `false` 로 선언한다. 그러지 않으면
+ * 운영자가 시험 삼아 미리보기에만 글자를 쳐도 [저장] 버튼이 켜져, 바뀐 것이 없는데
+ * 바뀐 것처럼 보인다. 어느 편집기가 저장 대상인지는 **레이아웃이 안다** — 그래서
+ * 핸들러가 필드명을 알아보는 대신 선언으로 받는다.
+ */
+ trackChanges?: boolean | string;
}
/** 툴바 프리셋 */
@@ -1100,12 +1109,20 @@ function createMultilingualTabs(
* 테스트를 위해 export 한다 — `hasChanges` 를 본문과 분리해 보내는 계약은 화면에서
* "저장 버튼이 계속 비활성" 으로만 드러나므로 단위 테스트로 잠근다
* (`resolveSingleContent` 와 같은 선례).
+ *
+ * @param name 폼 필드명
+ * @param locale 이 값이 속한 로케일 (다국어 모드에서만 경로에 반영)
+ * @param value 편집기가 내놓은 HTML
+ * @param isMultilingual 다국어 편집기 여부 (`form.{name}.{locale}` vs `form.{name}`)
+ * @param tracksChanges 이 입력을 폼 변경(`_local.hasChanges`)으로 칠지 여부. 기본 `true`
+ * @return void
*/
export function syncToForm(
name: string,
locale: string,
value: string,
- isMultilingual: boolean
+ isMultilingual: boolean,
+ tracksChanges: boolean = true
): void {
// setData() 호출 중 change:data 재진입 방지
if (isSyncSuppressed()) return;
@@ -1127,7 +1144,11 @@ export function syncToForm(
// 이 플래그는 스칼라 한 개라 본문과 달리 성능 사유가 없다. 그래서 렌더를 일으키는 일반
// setLocal 로 분리하되, **false → true 로 처음 넘어갈 때만** 보낸다. 이미 true 면 건너뛰므로
// 편집 세션당 추가 렌더는 최대 1회다.
- if (G7Core.state.getLocal?.()?.hasChanges !== true) {
+ //
+ // 저장 대상이 아닌 편집기(설정 화면의 미리보기 등)는 `tracksChanges:false` 로 이 축에서
+ // 빠진다 — 그러지 않으면 시험 입력만으로 [저장] 이 켜져 바뀐 것이 없는데 바뀐 것처럼 보인다.
+ // 본문 동기화는 그런 편집기도 그대로 수행한다(미리보기 렌더가 그 값을 읽는다).
+ if (tracksChanges && G7Core.state.getLocal?.()?.hasChanges !== true) {
G7Core.state.setLocal({ hasChanges: true });
}
@@ -1307,6 +1328,9 @@ export async function initEditorHandler(
const placeholder = params.placeholder ?? '';
const height = params.height !== undefined ? (Number(params.height) || 400) : (Number(pluginSettings.editorHeight) || 400);
const toolbarType = (params.toolbar !== undefined ? (params.toolbar as string) : (pluginSettings.toolbar as string)) ?? 'standard';
+ // 기본은 true — 저장 대상이 아닌 편집기만 레이아웃이 명시적으로 끈다.
+ // 미평가 표현식(`{{...}}`)이 그대로 들어와도 truthy 문자열이라 기본값 쪽으로 떨어진다.
+ const tracksChanges = !(params.trackChanges === false || params.trackChanges === 'false');
// content 파싱: 다국어 시 객체, 단일 시 문자열
// extensionPointProps를 통해 전달되는 경우 표현식이 평가되지 않고 raw 문자열로 전달될 수 있음
@@ -1326,6 +1350,7 @@ export async function initEditorHandler(
height,
readOnly: isReadOnly,
placeholder,
+ trackChanges: tracksChanges,
multilingual: isMultilingual,
locales: isMultilingual ? getSupportedLocales() : undefined,
activeLocale: getCurrentLocale(),
@@ -1397,7 +1422,7 @@ export async function initEditorHandler(
if (!externalSync.shouldEmit()) return;
const html = (editor as any).getData();
externalSync.noteEmitted(html);
- syncToForm(name, locale, html, true);
+ syncToForm(name, locale, html, true, tracksChanges);
updateCheckIconsRef.current();
});
@@ -1447,6 +1472,7 @@ export async function initEditorHandler(
locales,
activeLocale,
contentMap,
+ trackChanges: tracksChanges,
});
notifyEditorAssetFailure(
`ckeditor5-editor:${containerId}`,
@@ -1503,7 +1529,7 @@ export async function initEditorHandler(
if (!externalSync.shouldEmit()) return;
const html = (editor as any).getData();
externalSync.noteEmitted(html);
- syncToForm(name, singleLocale, html, false);
+ syncToForm(name, singleLocale, html, false, tracksChanges);
});
isInitializing = false;
@@ -1542,6 +1568,7 @@ export async function initEditorHandler(
multilingual: false,
activeLocale: getCurrentLocale(),
initialContent,
+ trackChanges: tracksChanges,
});
notifyEditorAssetFailure(
`ckeditor5-editor:${containerId}`,
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/textareaFallback.ts b/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/textareaFallback.ts
index f359d637..bab1e70b 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/textareaFallback.ts
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/handlers/textareaFallback.ts
@@ -39,6 +39,13 @@ export interface TextareaFallbackOptions {
contentMap?: Record;
/** 단일 모드 초기값 */
initialContent?: string;
+ /**
+ * 이 입력을 "폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부. 기본 `true`.
+ *
+ * 편집기 쪽 `trackChanges` 와 같은 축이다 — 저장 대상이 아닌 편집기가 폴백으로
+ * 내려앉아도 저장 대상이 아닌 것은 그대로여야 한다.
+ */
+ trackChanges?: boolean;
}
/** 컨테이너별 폴백 상태 (재시도 시 값 승계에 쓴다) */
@@ -54,20 +61,42 @@ const fallbackState = new WeakMap): void {
+function syncFallbackToForm(
+ name: string,
+ value: string | Record,
+ userInitiated: boolean,
+ tracksChanges: boolean
+): void {
const G7Core = (window as any).G7Core;
if (!G7Core?.state?.setLocal || !name) {
return;
}
+ // 렌더를 일으키는 별도 setLocal. false → true 로 처음 넘어갈 때만 보내므로
+ // 편집 세션당 추가 렌더는 최대 1회다 (`syncToForm` 과 같은 규칙).
+ if (userInitiated && tracksChanges && G7Core.state.getLocal?.()?.hasChanges !== true) {
+ G7Core.state.setLocal({ hasChanges: true });
+ }
+
const updates: Record = {
[`form.${name}_mode`]: 'text',
- hasChanges: true,
};
if (typeof value === 'string') {
@@ -99,6 +128,9 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
container.innerHTML = '';
+ // 기본은 true — 저장 대상이 아닌 편집기(설정 화면 미리보기 등)만 명시적으로 끈다.
+ const tracksChanges = options.trackChanges !== false;
+
const locales = multilingual ? (options.locales ?? []) : [];
const activeLocale = options.activeLocale ?? locales[0] ?? '';
@@ -169,7 +201,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
currentLocale = locale;
textarea.value = values[locale] ?? '';
textarea.dataset.locale = locale;
- syncFallbackToForm(name, values);
+ syncFallbackToForm(name, values, false, tracksChanges);
refreshTabs();
});
@@ -183,7 +215,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
textarea.addEventListener('input', () => {
values[currentLocale] = textarea.value;
- syncFallbackToForm(name, values);
+ syncFallbackToForm(name, values, true, tracksChanges);
refreshTabs();
});
} else {
@@ -191,7 +223,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
textarea.addEventListener('input', () => {
values[''] = textarea.value;
- syncFallbackToForm(name, textarea.value);
+ syncFallbackToForm(name, textarea.value, true, tracksChanges);
});
}
@@ -201,7 +233,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
// 폴백으로 내려앉았다는 사실 자체를 폼 상태에 반영한다 — 사용자가 아무것도 입력하지
// 않고 저장해도 서버가 평문으로 처리하도록.
- syncFallbackToForm(name, multilingual ? values : (values[''] ?? ''));
+ syncFallbackToForm(name, multilingual ? values : (values[''] ?? ''), false, tracksChanges);
}
/**
diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json
index 4a2a10c2..e1bc34f7 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json
+++ b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json
@@ -700,11 +700,13 @@
"id": "preview_editor",
"type": "extension_point",
"name": "html_editor",
+ "comment": "trackChanges:false — 미리보기는 설정을 시험해 보는 자리라 저장 대상이 아니다(위 저장 액션의 body 에서도 제외된다). 이것을 빼면 미리보기에 글자만 쳐도 _local.hasChanges 가 켜져 [저장] 버튼이 활성화되고, 운영자는 바뀐 것이 없는데 바뀐 줄 알고 누르게 된다. 어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명으로 알아보게 하지 않고 여기서 선언한다.",
"props": {
"name": "preview_content",
"content": "",
"placeholder": "$t:sirsoft-ckeditor5.settings.preview_placeholder",
- "multilingual": true
+ "multilingual": true,
+ "trackChanges": false
},
"default": [
{
diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
index 034c3baa..b2edba69 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
+++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
@@ -165,7 +165,11 @@ test.describe('편집기 자산 실패 폴백', () => {
test('폭을 바꾼 뒤 저장해도 폴백 입력창의 본문이 그대로 전송된다', async ({ page, editorToken }) => {
// @scenario asset_class=vendored, outcome=failed
- // @effects failed_asset_falls_back_to_plain_input
+ // @effects failed_asset_falls_back_to_plain_input, fallback_body_survives_resize_on_save
+ //
+ // 이 케이스는 두 매니페스트에 걸친다 — 폴백이 서는 것 자체는
+ // `self-hosted-runtime-assets.yaml`, 폭 변경 후 본문이 살아남는 것은
+ // `editor-resize-save-body-integrity.yaml` 이 소유한다.
//
// 공개 #130(engine-v1.63.3) 인접 축. 평문 폴백도 본문을 `setLocal({ render:false,
// selfManaged:true })` 로 저장소 B 에만 쓰므로, 편집기 경로와 **같은 조건**이 성립한다.
diff --git a/tests/Unit/Extension/GeneratesComponentManifestNewlineTest.php b/tests/Unit/Extension/GeneratesComponentManifestNewlineTest.php
new file mode 100644
index 00000000..0e0763b4
--- /dev/null
+++ b/tests/Unit/Extension/GeneratesComponentManifestNewlineTest.php
@@ -0,0 +1,105 @@
+buildPath = sys_get_temp_dir().'/g7-cm-'.uniqid();
+ mkdir($this->buildPath.'/resources/js/components/basic', 0o777, true);
+ file_put_contents($this->buildPath.'/plugin.json', json_encode(['version' => '1.2.3']));
+ file_put_contents($this->buildPath.'/resources/js/components/basic/Foo.tsx', 'export const Foo = () => null;');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDirectory($this->buildPath);
+
+ parent::tearDown();
+ }
+
+ /**
+ * 트레이트를 쓰는 익명 클래스로 생성기를 호출합니다.
+ *
+ * @return array{written: bool, count: int, path: string} 작성 결과
+ */
+ private function generate(): array
+ {
+ $generator = new class
+ {
+ use GeneratesComponentManifest;
+
+ /**
+ * protected 생성기를 테스트에서 호출하기 위한 통로입니다.
+ *
+ * @param string $buildPath 빌드 경로
+ * @return array{written: bool, count: int, path: string} 작성 결과
+ */
+ public function run(string $buildPath): array
+ {
+ return $this->generateComponentManifest($buildPath, 'sirsoft-sample');
+ }
+ };
+
+ return $generator->run($this->buildPath);
+ }
+
+ /**
+ * 디렉토리를 재귀 삭제합니다.
+ *
+ * @param string $dir 삭제할 디렉토리
+ * @return void
+ */
+ private function removeDirectory(string $dir): void
+ {
+ if (! is_dir($dir)) {
+ return;
+ }
+
+ foreach (scandir($dir) ?: [] as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ $path = $dir.'/'.$entry;
+ is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
+ }
+
+ @rmdir($dir);
+ }
+
+ public function test_manifest_ends_with_lf_not_platform_eol(): void
+ {
+ $result = $this->generate();
+
+ $this->assertTrue($result['written']);
+
+ $raw = file_get_contents($result['path']);
+
+ $this->assertStringEndsWith("}\n", $raw, 'LF 로 끝나야 한다');
+ $this->assertStringNotContainsString("\r", $raw, '어느 줄에도 CR 이 없어야 한다 — 있으면 Windows 빌드에서만 변경으로 잡힌다');
+ }
+
+ public function test_rebuilding_the_same_source_produces_identical_bytes(): void
+ {
+ $first = file_get_contents($this->generate()['path']);
+ $second = file_get_contents($this->generate()['path']);
+
+ $this->assertSame($first, $second, '같은 소스를 다시 빌드하면 바이트까지 같아야 한다');
+ }
+}
diff --git a/tests/scenarios/editor-resize-save-body-integrity.yaml b/tests/scenarios/editor-resize-save-body-integrity.yaml
index 658f92f7..73c29191 100644
--- a/tests/scenarios/editor-resize-save-body-integrity.yaml
+++ b/tests/scenarios/editor-resize-save-body-integrity.yaml
@@ -24,6 +24,10 @@ test_files:
- resources/js/core/__tests__/G7CoreGlobals.selfManagedPromotion.test.ts
- resources/js/core/template-engine/__tests__/DynamicRenderer.autoBindingPendingBase.test.tsx
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/editor-resize-save-body-integrity.spec.ts
+ # 편집기 확보 실패 시의 평문 폴백도 같은 조건(저장소 B 전용 쓰기)을 만든다 — 그 축을
+ # 잠그는 케이스가 이 spec 안에 있으므로 여기에 함께 건다. 자산 폴백 자체의 계약은
+ # self-hosted-runtime-assets.yaml 이 소유한다.
+ - plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
axes:
save_flow: [create, edit]
@@ -43,6 +47,7 @@ effects:
- autobinding_keystroke_preserves_editor_content
- pending_snapshot_carries_forced_overlay
- typed_path_wins_over_forced_previous_value
+ - fallback_body_survives_resize_on_save
sub_flows:
- id: control_no_resize
@@ -92,3 +97,14 @@ sub_flows:
- autobinding_keystroke_preserves_editor_content
- pending_snapshot_carries_forced_overlay
- typed_path_wins_over_forced_previous_value
+
+ - id: plain_fallback_after_resize
+ description: |
+ 편집기 확보에 실패해 평문 입력창으로 내려간 경우도 본문을 `setLocal({ render:false,
+ selfManaged:true })` 로 저장소 B 에만 쓴다 — 이 결함의 성립 조건이 그대로 성립한다.
+ 폴백은 디바운스를 쓰지 않아 입력 즉시 B 에 실리므로, 편집기 축의 결정화 4단계 중
+ ①(디바운스 발화 대기)만 빠지고 ②폭 변경 → ③pending 클리어 확정 → ④저장은 동일하다.
+
+ 이 축이 별도로 필요한 이유: 폐쇄망·차단 환경에서는 폴백이 **정상 경로**다. 편집기 축만
+ 잠그면 그 환경의 운영자에게는 결함이 남은 채로 남는다.
+ effects: [fallback_body_survives_resize_on_save]