From 151a7ae9451ed206c606adb24625f2636323f7fb Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 1 Sep 2026 21:53:47 +0900 Subject: [PATCH] =?UTF-8?q?fix(extensions,core):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=EA=B0=80=20=ED=8F=BC=EC=9D=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=A8=EC=9C=BC=EB=A1=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미리보기 편집기는 설정을 시험해 보는 자리인데, 본문 동기화가 hasChanges 를 렌더를 일으키는 setLocal 로 올리면서 시험 입력만으로 [저장] 이 켜졌다. 어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명을 알아보게 하지 않고 trackChanges 선언으로 받는다 — 하드코딩하면 다른 확장이 같은 미리보기 패턴을 쓸 때 그대로 재발한다. 같은 결함이 형제 경로에도 있었다. 평문 폴백은 hasChanges 를 본문 배치 (render:false + selfManaged:true)에 실어, 편집기 자산을 못 불러온 환경에서 본문만 고치면 저장 버튼이 잠긴 채로 남았다 — 폐쇄망에서는 그 폴백이 정상 경로다. 폴백은 렌더 시점에도 같은 함수를 부르므로 화면을 여는 것만으로 변경됨이 되지 않도록, 사용자 입력에서 온 호출만 플래그를 올린다. components.json 생성기는 종결 개행을 PHP_EOL 로 써서 Windows 빌드마다 그 파일을 유령 변경으로 남겼다. 줄 내용이 같아 diff 가 비어 보이므로 무엇이 바뀐 것인지 알 수 없는 변경만 쌓인다. --- .../Traits/GeneratesComponentManifest.php | 6 +- docs/frontend/state-management.md | 1 - plugins/_bundled/sirsoft-ckeditor5/AGENTS.md | 2 +- .../sirsoft-ckeditor5/dist/js/plugin.iife.js | 8 +- .../docs/extension-points.md | 14 ++ .../resources/extensions/html-editor.json | 3 +- .../layouts/html-editor-fallback.test.tsx | 10 +- .../initEditor-has-changes-render.test.tsx | 199 ++++++++++++++++++ .../pluginSettingsPreviewNotSaved.test.tsx | 131 ++++++++++++ .../resources/js/handlers/initEditor.ts | 35 ++- .../resources/js/handlers/textareaFallback.ts | 44 +++- .../layouts/admin/plugin_settings.json | 4 +- .../Playwright/specs/editor-fallback.spec.ts | 6 +- .../GeneratesComponentManifestNewlineTest.php | 105 +++++++++ .../editor-resize-save-body-integrity.yaml | 16 ++ 15 files changed, 563 insertions(+), 21 deletions(-) create mode 100644 plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPreviewNotSaved.test.tsx create mode 100644 tests/Unit/Extension/GeneratesComponentManifestNewlineTest.php 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]