v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -0,0 +1,15 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-09
### Added
- CKEditor 5 WYSIWYG 에디터 플러그인 최초 릴리즈
- extension_point를 통한 HtmlEditor/HtmlContent 자동 교체 (비활성화 시 폴백)
- 이미지 업로드/서빙 API
- 다국어 탭, 소스 편집, 다크모드 지원
- 플러그인 설정 UI (툴바, 에디터 높이, 이미지 업로드)
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 CKEditor 5 WYSIWYG 에디터 플러그인 (sirsoft-ckeditor5)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,20 @@
{
"name": "plugins/sirsoft-ckeditor5",
"description": "CKEditor 5 WYSIWYG Editor Plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Ckeditor5\\": ["src/", "./"]
}
}
}
@@ -0,0 +1,18 @@
{
"_meta": {
"version": "1.0.0",
"description": "CKEditor5 플러그인 환경설정"
},
"defaults": {
"imageUpload": true,
"imageMaxSizeMb": 2,
"editorHeight": 400,
"toolbar": "standard"
},
"frontend_schema": {
"imageUpload": { "expose": true },
"imageMaxSizeMb": { "expose": true },
"editorHeight": { "expose": true },
"toolbar": { "expose": true }
}
}
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 마이그레이션 실행
*/
public function up(): void
{
Schema::create('ckeditor5_image_uploads', function (Blueprint $table) {
$table->id()->comment('고유 ID');
$table->string('hash', 12)->unique()->comment('URL용 고유 해시 (12자)');
$table->string('original_name')->comment('원본 파일명');
$table->string('file_path', 1000)->comment('저장 파일 경로');
$table->string('storage_disk', 50)->default('public')->comment('스토리지 디스크');
$table->unsignedBigInteger('file_size')->comment('파일 크기(bytes)');
$table->string('mime_type', 100)->comment('MIME 타입');
$table->unsignedBigInteger('uploaded_by')->nullable()->comment('업로드 사용자 ID');
$table->timestamps();
$table->index('uploaded_by');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('ckeditor5_image_uploads', function (Blueprint $table) {
$table->comment('CKEditor5 이미지 업로드 기록');
});
}
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (Schema::hasTable('ckeditor5_image_uploads')) {
Schema::dropIfExists('ckeditor5_image_uploads');
}
}
};
@@ -0,0 +1,373 @@
var SirsoftCkeditor5=(function(M){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={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 R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
/* 다국어 탭 컨테이너 */
.ckeditor5-locale-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
/* 다국어 탭 버튼 - HtmlEditor 언어탭 스타일 */
.ckeditor5-locale-tabs button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 9999px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s;
}
/* 비활성 - 기본언어 (blue) */
.ckeditor5-locale-tabs button.is-default {
background: #eff6ff;
color: #2563eb;
}
.ckeditor5-locale-tabs button.is-default:hover {
background: #dbeafe;
}
/* 비활성 - 비기본언어 (gray) */
.ckeditor5-locale-tabs button:not(.is-default) {
background: #f3f4f6;
color: #4b5563;
}
.ckeditor5-locale-tabs button:not(.is-default):hover {
background: #e5e7eb;
}
/* 활성 - 기본언어 */
.ckeditor5-locale-tabs button.is-active.is-default {
background: #3b82f6;
color: #ffffff;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
transform: scale(1.05);
}
/* 활성 - 비기본언어 */
.ckeditor5-locale-tabs button.is-active:not(.is-default) {
background: #6b7280;
color: #ffffff;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
transform: scale(1.05);
}
.ckeditor5-locale-tabs button .ck5-lang-icon {
font-size: 0.75rem;
line-height: 1;
}
.ckeditor5-locale-tabs button .ck5-required {
color: #ef4444;
}
.ckeditor5-locale-tabs button.is-active .ck5-required {
color: #fca5a5;
}
.ckeditor5-locale-tabs button .ck5-check {
color: #22c55e;
}
/* 다크 모드 - 비활성 기본언어 */
html.dark .ckeditor5-locale-tabs button.is-default {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
html.dark .ckeditor5-locale-tabs button.is-default:hover {
background: rgba(59, 130, 246, 0.25);
}
/* 다크 모드 - 비활성 비기본언어 */
html.dark .ckeditor5-locale-tabs button:not(.is-default) {
background: #374151;
color: #d1d5db;
}
html.dark .ckeditor5-locale-tabs button:not(.is-default):hover {
background: #4b5563;
}
/* 다크 모드 - 활성 기본언어 */
html.dark .ckeditor5-locale-tabs button.is-active.is-default {
background: #2563eb;
color: #ffffff;
}
/* 다크 모드 - 활성 비기본언어 */
html.dark .ckeditor5-locale-tabs button.is-active:not(.is-default) {
background: #4b5563;
color: #ffffff;
}
html.dark .ckeditor5-locale-tabs button .ck5-check {
color: #4ade80;
}
/* 표 너비 인라인 스타일 무력화 - 컨테이너에 맞게 100% */
.ck-content figure.table { width: 100%; }
.ck-content figure.table table { width: 100%; }
/* 표 tr 배경색 초기화 */
.ck-content table tr { background-color: unset; }
/* 표 col 절대 px 너비 무력화 - 컨테이너에 맞게 비율로 */
.ck-content table col { width: auto !important; }
/* 표 정렬 보정: CKEditor5 기본 CSS의 margin-left:auto를 float으로 덮어씀 */
.ck-content figure.table[style*="float:left"],
.ck-content figure.table[style*="float: left"] {
margin-left: 0 !important;
margin-right: 1em !important;
}
.ck-content figure.table[style*="float:right"],
.ck-content figure.table[style*="float: right"] {
margin-left: 1em !important;
margin-right: 0 !important;
}
/* 이미지 캡션 - 라이트 모드 */
.ck-content .image > figcaption {
background-color: #f3f4f6;
color: #374151;
font-size: 0.875em;
padding: 4px 8px;
text-align: center;
}
/* 에디터 편집 영역 - 목록 마커 복원 (Tailwind preflight reset 대응) */
.ck.ck-editor__editable ul,
.ck-content ul {
list-style-type: disc;
padding-left: 2em;
}
.ck.ck-editor__editable ol,
.ck-content ol {
list-style-type: decimal;
padding-left: 2em;
}
.ck.ck-editor__editable ul ul,
.ck-content ul ul {
list-style-type: circle;
padding-left: 2em;
}
.ck.ck-editor__editable ul ul ul,
.ck-content ul ul ul {
list-style-type: square;
padding-left: 2em;
}
.ck.ck-editor__editable ol ol,
.ck-content ol ol {
list-style-type: lower-alpha;
padding-left: 2em;
}
.ck.ck-editor__editable ol ol ol,
.ck-content ol ol ol {
list-style-type: lower-roman;
padding-left: 2em;
}
`,document.head.appendChild(t)}function et(){return document.documentElement.classList.contains("dark")}function q(){const t=document.getElementById(H);if(!et()){t&&t.remove();return}if(t)return;const o=document.createElement("style");o.id=H,o.textContent=`
/* CKEditor5 다크 모드 오버라이드 - CSS 변수 전역 적용 */
:root {
--ck-color-base-foreground: #1f2937;
--ck-color-base-background: #1f2937;
--ck-color-base-border: #374151;
--ck-color-text: #f3f4f6;
--ck-color-toolbar-background: #111827;
--ck-color-toolbar-border: #374151;
--ck-color-button-default-background: transparent;
--ck-color-button-default-hover-background: #374151;
--ck-color-button-default-active-background: #4b5563;
--ck-color-button-on-background: #374151;
--ck-color-button-on-hover-background: #4b5563;
--ck-color-focus-border: #3b82f6;
--ck-color-input-background: #1f2937;
--ck-color-input-text: #f3f4f6;
--ck-color-input-border: #374151;
--ck-color-panel-background: #1f2937;
--ck-color-panel-border: #374151;
--ck-color-list-button-hover-background: #374151;
--ck-color-list-button-on-background: #3b82f6;
--ck-color-list-button-on-color: #f3f4f6;
--ck-color-list-button-on-hover-background: #4b5563;
--ck-color-labeled-field-label-background: #1f2937;
--ck-color-link-default: #60a5fa;
--ck-color-link-selected-background: rgba(96, 165, 250, 0.1);
--ck-color-table-focused-cell-background: rgba(59, 130, 246, 0.1);
}
/* 에디터 본문 */
.ck.ck-editor__editable_inline {
background-color: #1f2937 !important;
color: #f3f4f6 !important;
border-color: #374151 !important;
}
/* 메인 툴바 */
.ck.ck-toolbar {
background-color: #111827 !important;
border-color: #374151 !important;
}
/* 모든 버튼 텍스트 */
.ck.ck-button,
.ck.ck-button .ck-button__label {
color: #f3f4f6 !important;
}
/* 모든 SVG 아이콘 (툴바, 드롭다운 화살표 포함) */
.ck.ck-icon,
.ck .ck-icon,
.ck.ck-button .ck-icon,
.ck.ck-dropdown__arrow {
color: #f3f4f6 !important;
}
.ck.ck-button:hover,
.ck.ck-button.ck-on {
background-color: #374151 !important;
}
/* 툴팁 (호버 시 버튼 설명) */
.ck.ck-tooltip .ck-tooltip__text {
background-color: #1f2937 !important;
color: #f3f4f6 !important;
}
/* 드롭다운 패널 / 리스트 / balloon 팝업 공통 */
.ck.ck-dropdown__panel,
.ck.ck-list,
.ck.ck-balloon-panel {
background-color: #1f2937 !important;
border-color: #374151 !important;
}
/* 리스트 아이템 */
.ck.ck-list__item .ck-button,
.ck.ck-list__item .ck-button .ck-button__label {
color: #f3f4f6 !important;
}
.ck.ck-list__item .ck-button:hover {
background-color: #374151 !important;
}
.ck.ck-list__item .ck-button.ck-disabled .ck-button__label {
color: #6b7280 !important;
}
/* 링크 팝업 / form */
.ck.ck-link-form,
.ck.ck-link-actions {
background-color: #1f2937 !important;
border-color: #374151 !important;
}
.ck.ck-input-text {
background-color: #111827 !important;
color: #f3f4f6 !important;
border-color: #374151 !important;
}
.ck.ck-labeled-field-view__label {
background-color: #1f2937 !important;
color: #9ca3af !important;
}
/* 테이블 속성/셀 속성 폼 */
.ck.ck-table-form,
.ck.ck-table-cell-properties-form,
.ck.ck-table-properties-form {
background-color: #1f2937 !important;
border-color: #374151 !important;
}
.ck.ck-table-form .ck-label,
.ck.ck-table-cell-properties-form .ck-label,
.ck.ck-table-properties-form .ck-label {
color: #d1d5db !important;
}
/* 색상 선택 패널 */
.ck.ck-color-table,
.ck.ck-color-grid {
background-color: #1f2937 !important;
}
.ck.ck-color-table .ck-color-table__remove-color,
.ck.ck-color-table .ck-color-table__remove-color .ck-button__label {
color: #f3f4f6 !important;
}
/* 헤딩/폰트 드롭다운 라벨 */
.ck.ck-heading_paragraph,
.ck.ck-heading_heading1,
.ck.ck-heading_heading2,
.ck.ck-heading_heading3 {
color: #f3f4f6 !important;
}
/* 이미지 캡션 */
.ck-content .image > figcaption {
background-color: #374151 !important;
color: #d1d5db !important;
}
/* 이미지 리사이즈 핸들 */
.ck-content .image.ck-widget_selected .ck-widget__resizer__handle {
background-color: #3b82f6 !important;
border-color: #1d4ed8 !important;
}
/* 이미지 정렬 툴바 아이콘 */
.ck.ck-toolbar .ck-button .ck-icon {
color: #f3f4f6 !important;
}
/* 이미지 선택 시 balloon toolbar (floating) */
.ck.ck-toolbar_floating {
background-color: #111827 !important;
border-color: #374151 !important;
}
/* 이미지 리사이즈 드롭다운 버튼 라벨 (25%, 50% 등) */
.ck.ck-dropdown .ck-button__label {
color: #f3f4f6 !important;
}
/* 이미지 리사이즈 드롭다운 내 선택 리스트 */
.ck.ck-resize-image-form,
.ck.ck-image-resize-form {
background-color: #1f2937 !important;
border-color: #374151 !important;
color: #f3f4f6 !important;
}
.ck.ck-resize-image-form .ck-label,
.ck.ck-image-resize-form .ck-label {
color: #d1d5db !important;
}
.ck.ck-resize-image-form .ck-input-text,
.ck.ck-image-resize-form .ck-input-text {
background-color: #111827 !important;
color: #f3f4f6 !important;
border-color: #374151 !important;
}
/* 표 안 에디터 내용 (다크 배경) */
.ck-content table tr {
background-color: unset;
}
.ck-content table td,
.ck-content table th {
border-color: #4b5563;
color: #f3f4f6 !important;
}
/* 표 col 절대 px 너비 무력화 */
.ck-content table col {
width: auto !important;
}
/* 목록 마커 색상 - 다크모드 */
.ck.ck-editor__editable ul,
.ck.ck-editor__editable ol {
color: #f3f4f6;
}
.ck.ck-editor__editable li::marker {
color: #d1d5db;
}
`,document.head.appendChild(o)}let x=null;function ot(){x||(x=new MutationObserver(()=>{q()}),x.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}))}function nt(){x&&(x.disconnect(),x=null)}const rt={ko:"한국어",en:"English",ja:"日本語",zh:"中文",fr:"Français",de:"Deutsch",es:"Español"};function ct(t){return rt[t]??t.toUpperCase()}function lt(){const t=window.G7Core;return t?.locale?.supported?t.locale.supported():[localStorage.getItem("g7_locale")||"ko"]}function I(){return window.G7Core?.locale?.current?.()??localStorage.getItem("g7_locale")??"ko"}function at(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 it(t){const o="/api/plugins/sirsoft-ckeditor5/upload";return t?`${o}?permission=${encodeURIComponent(t)}`:o}function dt(){const t=localStorage.getItem("auth_token");return t?{Authorization:`Bearer ${t}`}:{}}async function F(t,o){const e=window.CKEDITOR;if(!e)throw new Error("[sirsoft-ckeditor5] CKEDITOR 전역 객체를 찾을 수 없습니다. CDN 스크립트가 로드되었는지 확인하세요.");const r=O[o.toolbar]||O.standard,c=o.imageUpload?r:r.filter(n=>n!=="uploadImage"),l={initialData:o.initialContent,plugins:at(e,o.toolbar,o.imageUpload),toolbar:{items:c},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:n=>n.startsWith("http://")||n.startsWith("https://"),attributes:{target:"_blank",rel:"noopener noreferrer"}}}},htmlSupport:{allow:[{name:/.*/,attributes:!0,classes:!0,styles:!0}]},placeholder:o.placeholder,language:I()};o.imageUpload&&(l.simpleUpload={uploadUrl:it(o.uploadPermission),headers:dt()});const i=await e.ClassicEditor.create(t,l),s=`ckeditor5-height-style-${o.containerId}`,b=document.getElementById(s);if(b)b.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`;else{const n=document.createElement("style");n.id=s,n.textContent=`#${o.containerId} .ck-editor__editable { min-height: ${o.height}px !important; }`,document.head.appendChild(n)}return o.readOnly&&i.enableReadOnlyMode("read-only"),i}function st(t,o,e,r,c){const l=document.createElement("div");l.className="ckeditor5-locale-tabs";const i=new Map;o.forEach(n=>{const a=document.createElement("div");a.dataset.locale=n,n!==e&&(a.style.cssText="display:none;");const C=document.createElement("div");a.appendChild(C),i.set(n,C)});const s=()=>{c&&l.querySelectorAll("button").forEach(n=>{const a=n.dataset.locale;if(!a||n.classList.contains("is-active")){const h=n.querySelector(".ck5-check");h&&h.remove();return}const g=!!c(a)?.trim(),p=n.querySelector(".ck5-check");if(g&&!p){const h=document.createElement("i");h.className="fas fa-check ck5-check",n.appendChild(h)}else!g&&p&&p.remove()})},b=o[0];return o.forEach(n=>{const a=n===b,C=n===e,g=document.createElement("button");g.type="button",g.dataset.locale=n,g.className=[C?"is-active":"",a?"is-default":""].filter(Boolean).join(" "),g.innerHTML=`<i class="fas fa-globe ck5-lang-icon"></i><span>${ct(n)}</span>${a?'<span class="ck5-required">*</span>':""}`,g.addEventListener("click",async()=>{l.querySelectorAll("button").forEach(p=>{const L=p.dataset.locale===n;p.classList.toggle("is-active",L)}),i.forEach((p,h)=>{const L=p.parentElement;L&&(L.style.display=h===n?"block":"none")}),r&&await r(n),s()}),l.appendChild(g)}),t.appendChild(l),i.forEach(n=>{n.parentElement&&t.appendChild(n.parentElement)}),{tabsEl:l,contentEls:i,updateCheckIcons:s}}function T(t,o,e,r){const c=window.G7Core;if(!c?.state?.setLocal)return;const l={[`form.${t}_mode`]:"html",hasChanges:!0};r?l[`form.${t}.${o}`]=e:l[`form.${t}`]=e,c.state.setLocal(l,{debounce:300,debounceKey:`ckeditor-sync-${t}`,render:!1})}async function ut(t,o){const e=t.params??{},c=`ckeditor5-${e.name??"content"}`,l=E.get(c);if(l&&l.size>Line truncated
/* 이미지 캡션 - 다크모드 */
html.dark .ck-content .image > figcaption { color: hsl(0, 0%, 80%); background-color: hsl(0, 0%, 15%); }
/* CKEditor5 표 스타일 - 다크모드 */
html.dark .ck-content figure.table table { border-color: hsl(0, 0%, 35%); }
html.dark .ck-content figure.table table tr { background-color: unset; }
html.dark .ck-content figure.table table td, html.dark .ck-content figure.table table th { border-color: hsl(0, 0%, 30%); color: hsl(0, 0%, 90%); background-color: unset; }
html.dark .ck-content figure.table table th { background-color: hsl(215, 20%, 25%); }
/* figure.table 전체 너비 - prose와의 충돌 방지 */
.ck-content figure.table { display: table; width: 100%; margin: 1em 0; }
.ck-content figure.table table { width: 100%; }
.ck-content table tr { background-color: unset; }
.ck-content table col { width: auto !important; }
/* 표 정렬 보정 */
.ck-content figure.table[style*="float:left"],
.ck-content figure.table[style*="float: left"] { margin-left: 0 !important; margin-right: 1em !important; }
.ck-content figure.table[style*="float:right"],
.ck-content figure.table[style*="float: right"] { margin-left: 1em !important; margin-right: 0 !important; }
/* 이미지 인라인/정렬 - Tailwind preflight img:block 대응 */
.ck-content img { display: inline; margin: 0; max-width: 100%; }
.ck-content p:has(img) { line-height: 0; }
.ck-content p:has(img[style*="float"]) { overflow: hidden; }
/* 이미지 캡션 - 라이트 모드 */
.ck-content .image > figcaption { background-color: #f3f4f6; color: #374151; font-size: 0.875em; padding: 4px 8px; text-align: center; }
/* 목록 마커 및 들여쓰기 - Tailwind preflight reset 대응 */
.ck-content ul { list-style-type: disc; padding-left: 2em; }
.ck-content ol { list-style-type: decimal; padding-left: 2em; }
.ck-content ul ul { list-style-type: circle; padding-left: 2em; }
.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 v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),M.initPlugin=j,Object.defineProperty(M,Symbol.toStringTag,{value:"Module"}),M})({});
//# sourceMappingURL=plugin.iife.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/editorInstances.ts","../../resources/js/handlers/initEditor.ts","../../resources/js/handlers/destroyEditor.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * CKEditor5 인스턴스 공유 저장소\n *\n * containerId → locale별 에디터 인스턴스 맵을 관리합니다.\n * initEditor / destroyEditor 핸들러가 공유합니다.\n */\n\n// containerId → Map<locale, editorInstance>\nexport const editorInstances = new Map<string, Map<string, unknown>>();\n\n// setData() 중 change:data → syncToForm 재진입 방지 플래그\nlet _syncSuppressed = false;\nexport function isSyncSuppressed(): boolean {\n return _syncSuppressed;\n}\nexport function setSyncSuppressed(value: boolean): void {\n _syncSuppressed = value;\n}\n","/**\n * CKEditor5 초기화 핸들러\n *\n * 컴포넌트 마운트 시 CKEditor5 인스턴스를 생성합니다.\n * 다국어(multilingual) 모드: locale별 탭 + 인스턴스 생성\n * 단일 모드: 단일 인스턴스 생성\n */\n\nimport { editorInstances, isSyncSuppressed } from './editorInstances';\n\n/** CKEditor5 CDN CSS URL */\nconst CKEDITOR5_CSS_URL = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css';\n/** CKEditor5 CDN 번역 파일 base URL */\nconst CKEDITOR5_TRANSLATIONS_BASE = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/translations';\n/** CKEditor5 CSS 로드 상태 추적용 엘리먼트 ID */\nconst CKEDITOR5_CSS_ID = 'ckeditor5-cdn-css';\n/** CKEditor5 다크 모드 CSS 엘리먼트 ID */\nconst CKEDITOR5_DARK_CSS_ID = 'ckeditor5-dark-override';\n/** CKEditor5 다국어 탭 기본 CSS 엘리먼트 ID */\nconst CKEDITOR5_TAB_CSS_ID = 'ckeditor5-tab-style';\n\n/** CKEditor 전역 객체 타입 (CDN UMD 빌드 — window.CKEDITOR) */\ninterface CKEditorGlobal {\n ClassicEditor: any;\n Essentials: any;\n Bold: any;\n Italic: any;\n Underline: any;\n Strikethrough: any;\n Link: any;\n Paragraph: any;\n Heading: any;\n BlockQuote: any;\n List: any;\n Alignment: any;\n Table: any;\n TableToolbar: any;\n TableProperties: any;\n TableCellProperties: any;\n Image: any;\n ImageUpload: any;\n ImageResize: any;\n ImageStyle: any;\n ImageCaption: any;\n ImageToolbar: any;\n SimpleUploadAdapter: any;\n MediaEmbed: any;\n HorizontalLine: any;\n Indent: any;\n IndentBlock: any;\n CodeBlock: any;\n SourceEditing: any;\n FontSize: any;\n FontColor: any;\n FontBackgroundColor: any;\n PasteFromOffice: any;\n GeneralHtmlSupport: any;\n}\n\n/** initEditor 파라미터 타입 */\ninterface InitEditorParams {\n name?: string;\n content?: string;\n multilingual?: boolean | string;\n placeholder?: string;\n readOnly?: boolean | string;\n imageUpload?: boolean | string;\n uploadPermission?: string;\n height?: number | string;\n toolbar?: string;\n}\n\n/** 툴바 프리셋 */\nconst TOOLBAR_PRESETS: Record<string, string[]> = {\n minimal: [\n 'bold', 'italic', 'underline', '|',\n 'link', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n standard: [\n 'heading', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'insertTable', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n full: [\n 'heading', '|',\n 'fontSize', 'fontColor', 'fontBackgroundColor', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', 'insertTable', 'uploadImage', 'mediaEmbed', 'horizontalLine', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'codeBlock', 'sourceEditing', '|',\n 'undo', 'redo',\n ],\n};\n\n/**\n * CKEditor5 번역 스크립트를 동적으로 로드합니다.\n * 영어(en)는 기본 내장이므로 로드하지 않습니다.\n */\nfunction loadCkeditorTranslations(locale: string): Promise<void> {\n if (locale === 'en') return Promise.resolve();\n const id = `ckeditor5-translations-${locale}`;\n if (document.getElementById(id)) return Promise.resolve();\n return new Promise((resolve) => {\n const script = document.createElement('script');\n script.id = id;\n script.src = `${CKEDITOR5_TRANSLATIONS_BASE}/${locale}.umd.js`;\n script.onload = () => resolve();\n script.onerror = () => resolve(); // 번역 실패해도 에디터는 영어로 동작\n document.head.appendChild(script);\n });\n}\n\n/**\n * CKEditor5 CSS를 동적으로 로드합니다.\n * 이미 로드된 경우 중복 삽입하지 않습니다.\n */\nfunction loadCkeditorCss(): void {\n if (document.getElementById(CKEDITOR5_CSS_ID)) {\n return;\n }\n const link = document.createElement('link');\n link.id = CKEDITOR5_CSSLine truncated
@@ -0,0 +1,16 @@
<?php
return [
'image' => [
'not_found' => 'Image not found.',
],
'upload' => [
'required' => 'An image file is required.',
'invalid_file' => 'Invalid file.',
'not_image' => 'Only image files can be uploaded.',
'invalid_mime' => 'Only jpeg, jpg, png, gif, webp formats are supported.',
'too_large' => 'The file size exceeds the allowed limit (:max MB).',
'forbidden' => 'You do not have permission to upload images.',
'failed' => 'Image upload failed.',
],
];
@@ -0,0 +1,16 @@
<?php
return [
'image' => [
'not_found' => '이미지를 찾을 수 없습니다.',
],
'upload' => [
'required' => '업로드할 이미지 파일이 필요합니다.',
'invalid_file' => '유효하지 않은 파일입니다.',
'not_image' => '이미지 파일만 업로드할 수 있습니다.',
'invalid_mime' => 'jpeg, jpg, png, gif, webp 형식만 지원합니다.',
'too_large' => '파일 크기가 허용 용량(:max MB)을 초과합니다.',
'forbidden' => '이미지 업로드 권한이 없습니다.',
'failed' => '이미지 업로드에 실패했습니다.',
],
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
{
"name": "@g7/sirsoft-ckeditor5",
"version": "1.0.0-beta.1",
"description": "그누보드7 CKEditor 5 WYSIWYG 에디터 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
"vite": "^5.4.14",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,32 @@
{
"identifier": "sirsoft-ckeditor5",
"vendor": "sirsoft",
"name": {
"ko": "CKEditor 5 WYSIWYG 에디터",
"en": "CKEditor 5 WYSIWYG Editor"
},
"version": "1.0.0-beta.1",
"license": "MIT",
"description": {
"ko": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.",
"en": "WYSIWYG editor plugin using CKEditor 5. Installing this plugin replaces the default HtmlEditor."
},
"g7_version": ">=7.0.0-beta.1",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-ckeditor5",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-ckeditor5/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,90 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5;
use App\Extension\AbstractPlugin;
/**
* CKEditor 5 WYSIWYG 에디터 플러그인
*
* extension_point: "html_editor" 슬롯을 통해 기존 HtmlEditor를 교체합니다.
* 미설치 시 기존 HtmlEditor로 자동 폴백됩니다.
*/
class Plugin extends AbstractPlugin
{
/**
* 플러그인 설정 스키마 반환
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'imageUpload' => [
'type' => 'boolean',
'default' => true,
'label' => [
'ko' => '이미지 업로드',
'en' => 'Image Upload',
],
'hint' => [
'ko' => '에디터에서 이미지 업로드 기능을 활성화합니다.',
'en' => 'Enable image upload functionality in the editor.',
],
'required' => false,
],
'imageMaxSizeMb' => [
'type' => 'integer',
'default' => 2,
'label' => [
'ko' => '이미지 최대 크기 (MB)',
'en' => 'Image Max Size (MB)',
],
'hint' => [
'ko' => '업로드 가능한 이미지의 최대 파일 크기입니다.',
'en' => 'Maximum file size for uploadable images.',
],
'required' => false,
],
'editorHeight' => [
'type' => 'integer',
'default' => 400,
'label' => [
'ko' => '에디터 높이 (px)',
'en' => 'Editor Height (px)',
],
'hint' => [
'ko' => '에디터 영역의 최소 높이입니다.',
'en' => 'Minimum height of the editor area.',
],
'required' => false,
],
'toolbar' => [
'type' => 'enum',
'options' => ['standard', 'minimal', 'full'],
'default' => 'standard',
'label' => [
'ko' => '툴바 유형',
'en' => 'Toolbar Type',
],
'hint' => [
'ko' => '에디터 툴바 구성을 선택합니다.',
'en' => 'Select the editor toolbar configuration.',
],
'required' => false,
],
];
}
/**
* 플러그인이 관리하는 동적 테이블 목록 반환
*
* @return array 테이블명 배열
*/
public function getDynamicTables(): array
{
return [
'ckeditor5_image_uploads',
];
}
}
@@ -0,0 +1,32 @@
{
"extension_point": "html_content",
"mode": "replace",
"components": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{extensionPointProps.content ?? ''}}",
"isHtml": "{{extensionPointProps.isHtml ?? true}}",
"className": "{{(extensionPointProps.className ?? '') + ' ck-content'}}",
"purifyConfig": "{{extensionPointProps.purifyConfig ?? undefined}}"
}
},
{
"id": "ckeditor5_content_css_injector",
"type": "basic",
"name": "Div",
"props": {
"className": "hidden"
},
"lifecycle": {
"onMount": [
{
"handler": "sirsoft-ckeditor5.injectContentCss"
}
]
}
}
],
"priority": 100
}
@@ -0,0 +1,48 @@
{
"extension_point": "html_editor",
"mode": "replace",
"scripts": [
{
"src": "https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.umd.js",
"id": "ckeditor5_script"
}
],
"components": [
{
"id": "ckeditor5_container",
"type": "basic",
"name": "Div",
"props": {
"id": "ckeditor5-{{extensionPointProps.name ?? 'content'}}",
"className": "ckeditor5-wrapper"
},
"lifecycle": {
"onMount": [
{
"handler": "sirsoft-ckeditor5.initEditor",
"params": {
"name": "{{extensionPointProps.name ?? 'content'}}",
"content": "{{extensionPointProps.content ?? extensionPointProps.value ?? ''}}",
"multilingual": "{{extensionPointProps.multilingual ?? false}}",
"placeholder": "{{extensionPointProps.placeholder ?? ''}}",
"readOnly": "{{_computed.isReadOnly ?? extensionPointProps.disabled ?? extensionPointProps.readOnly ?? false}}",
"imageUpload": "{{_global.plugins['sirsoft-ckeditor5']?.imageUpload ?? false}}",
"uploadPermission": "{{extensionPointProps.uploadPermission ?? ''}}",
"height": "{{_global.plugins['sirsoft-ckeditor5']?.editorHeight ?? 400}}",
"toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}"
}
}
],
"onUnmount": [
{
"handler": "sirsoft-ckeditor5.destroyEditor",
"params": {
"name": "{{extensionPointProps.name ?? 'content'}}"
}
}
]
}
}
],
"priority": 100
}
@@ -0,0 +1,313 @@
/**
* @file html-content.test.tsx
* @description sirsoft-ckeditor5 html_content extension_point 렌더링 테스트
*
* 테스트 환경 특성:
* - extension_point(mode: append)는 백엔드가 children에 컴포넌트를 추가
* - HtmlContent 컴포넌트 동작(HTML/텍스트 렌더링)은 type: "composite"로 직접 배치하여 검증
* - extension_point 구조 검증은 children을 직접 배치하여 시뮬레이션
*/
import React from 'react';
import { describe, it, expect } from 'vitest';
import {
createLayoutTest,
screen,
createMockComponentRegistry,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
function buildRegistry() {
const registry = createMockComponentRegistry();
registry.register('layout', 'Fragment', ({ children }: any) =>
React.createElement(React.Fragment, null, children)
);
registry.register('basic', 'Div', ({ children, className, id, ...rest }: any) =>
React.createElement('div', { className, id, ...rest }, children)
);
registry.register('composite', 'HtmlContent', ({ content, isHtml, className }: any) => {
if (!content || content.trim() === '') return null;
if (!isHtml) {
return React.createElement('div', {
'data-testid': 'html-content-text',
className,
}, content);
}
return React.createElement('div', {
'data-testid': 'html-content-html',
className,
dangerouslySetInnerHTML: { __html: content },
});
});
return registry;
}
// HtmlContent 직접 배치 — API 데이터 바인딩 검증
const htmlContentApiLayout = {
version: '1.0.0',
layout_name: 'test_html_content_api',
data_sources: [
{
id: 'post',
type: 'api',
endpoint: '/api/admin/boards/notice/posts/1',
method: 'GET',
auto_fetch: true,
},
],
components: [
{
id: 'post_content_wrapper',
type: 'basic',
name: 'Div',
props: { 'data-testid': 'post-content-wrapper' },
children: [
{
id: 'html_content_direct',
type: 'composite',
name: 'HtmlContent',
props: {
content: '{{post?.data?.content ?? ""}}',
isHtml: '{{(post?.data?.content_mode ?? "text") === "html"}}',
className: 'prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100',
},
},
],
},
],
};
// extension_point(mode: append) — 플러그인 설치 시 시뮬레이션
// 백엔드가 원본 HtmlContent + CSS 주입 컴포넌트를 children에 추가
const htmlContentWithPluginLayout = {
version: '1.0.0',
layout_name: 'test_html_content_with_plugin',
data_sources: [
{
id: 'post',
type: 'api',
endpoint: '/api/admin/boards/notice/posts/1',
method: 'GET',
auto_fetch: true,
},
],
components: [
{
id: 'normal_content',
type: 'extension_point',
name: 'html_content',
props: {
content: '{{post?.data?.content ?? ""}}',
isHtml: '{{(post?.data?.content_mode ?? "text") === "html"}}',
className: 'prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100',
},
// 백엔드 주입: 원본 HtmlContent + CSS 주입 컴포넌트
children: [
{
type: 'composite',
name: 'HtmlContent',
props: {
content: '{{post?.data?.content ?? ""}}',
isHtml: '{{(post?.data?.content_mode ?? "text") === "html"}}',
className: 'prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100',
},
},
{
id: 'ckeditor5_content_css_injector',
type: 'basic',
name: 'Div',
props: { className: 'hidden', 'data-testid': 'css-injector' },
},
],
},
],
};
// 댓글 콘텐츠 — initialData 바인딩 검증
const replyContentLayout = {
version: '1.0.0',
layout_name: 'test_reply_content',
components: [
{
id: 'reply_html_content',
type: 'composite',
name: 'HtmlContent',
props: {
content: '{{item?.content ?? ""}}',
isHtml: '{{(item?.content_mode ?? "text") === "html"}}',
className: 'prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100',
},
},
],
};
describe('HtmlContent 컴포넌트 — HTML/텍스트 렌더링 (API 바인딩)', () => {
it('isHtml=true 시 HTML 콘텐츠가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlContentApiLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: {
id: 1,
content: '<p>HTML <strong>게시글</strong> 내용입니다.</p>',
content_mode: 'html',
},
},
});
await testUtils.render();
const htmlContent = screen.getByTestId('html-content-html');
expect(htmlContent).toBeInTheDocument();
expect(htmlContent.innerHTML).toContain('<strong>게시글</strong>');
testUtils.cleanup();
});
it('prose 클래스가 적용된다', async () => {
const testUtils = createLayoutTest(htmlContentApiLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '<p>내용</p>', content_mode: 'html' },
},
});
await testUtils.render();
const htmlContent = screen.getByTestId('html-content-html');
expect(htmlContent.className).toContain('prose');
testUtils.cleanup();
});
it('isHtml=false 시 일반 텍스트로 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlContentApiLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '일반 텍스트 내용입니다.', content_mode: 'text' },
},
});
await testUtils.render();
const textContent = screen.getByTestId('html-content-text');
expect(textContent).toBeInTheDocument();
expect(textContent.textContent).toBe('일반 텍스트 내용입니다.');
testUtils.cleanup();
});
it('content가 빈 문자열이면 렌더링되지 않는다', async () => {
const testUtils = createLayoutTest(htmlContentApiLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '', content_mode: 'html' },
},
});
await testUtils.render();
expect(screen.queryByTestId('html-content-html')).not.toBeInTheDocument();
expect(screen.queryByTestId('html-content-text')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('래퍼 div가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlContentApiLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '내용', content_mode: 'text' },
},
});
await testUtils.render();
expect(screen.getByTestId('post-content-wrapper')).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('html_content extension_point — 플러그인 설치 시 (CSS 주입 + HtmlContent)', () => {
it('extension_point 컨테이너 id가 적용된다', async () => {
const testUtils = createLayoutTest(htmlContentWithPluginLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '<p>내용</p>', content_mode: 'html' },
},
});
await testUtils.render();
const container = document.getElementById('normal_content');
expect(container).toBeInTheDocument();
testUtils.cleanup();
});
it('HtmlContent와 CSS 주입 컴포넌트가 함께 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlContentWithPluginLayout, {
componentRegistry: buildRegistry(),
});
testUtils.mockApi('post', {
response: {
success: true,
data: { id: 1, content: '<p>HTML 내용</p>', content_mode: 'html' },
},
});
await testUtils.render();
expect(screen.getByTestId('html-content-html')).toBeInTheDocument();
expect(screen.getByTestId('css-injector')).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('html_content — 댓글 콘텐츠 패턴 (_reply_card_content.json)', () => {
it('댓글 HTML 콘텐츠가 렌더링된다', async () => {
const testUtils = createLayoutTest(replyContentLayout, {
componentRegistry: buildRegistry(),
initialData: {
item: {
id: 10,
content: '<p>댓글 <em>내용</em>입니다.</p>',
content_mode: 'html',
},
},
});
await testUtils.render();
const htmlContent = screen.getByTestId('html-content-html');
expect(htmlContent).toBeInTheDocument();
expect(htmlContent.innerHTML).toContain('<em>내용</em>');
testUtils.cleanup();
});
it('댓글 텍스트 콘텐츠가 렌더링된다', async () => {
const testUtils = createLayoutTest(replyContentLayout, {
componentRegistry: buildRegistry(),
initialData: {
item: {
id: 10,
content: '일반 댓글 내용',
content_mode: 'text',
},
},
});
await testUtils.render();
const textContent = screen.getByTestId('html-content-text');
expect(textContent).toBeInTheDocument();
expect(textContent.textContent).toBe('일반 댓글 내용');
testUtils.cleanup();
});
});
@@ -0,0 +1,225 @@
/**
* @file html-editor.test.tsx
* @description sirsoft-ckeditor5 html_editor extension_point 렌더링 테스트
*
* 테스트 환경 특성:
* - extension_point는 백엔드 주입 없이 children을 렌더링
* - 플러그인 미설치 = children 없음 → 빈 컨테이너
* - 플러그인 설치 = 백엔드가 html-editor.json 컴포넌트를 children에 주입
* - HtmlEditor 컴포넌트 동작은 type: "composite"로 직접 배치하여 검증
*/
import React from 'react';
import { describe, it, expect } from 'vitest';
import {
createLayoutTest,
screen,
createMockComponentRegistry,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
function buildRegistry(extra?: Record<string, React.FC<any>>) {
const registry = createMockComponentRegistry();
registry.register('layout', 'Fragment', ({ children }: any) =>
React.createElement(React.Fragment, null, children)
);
registry.register('basic', 'Div', ({ children, className, id, ...rest }: any) =>
React.createElement('div', { className, id, ...rest }, children)
);
registry.register('composite', 'HtmlEditor', ({ name, content, placeholder }: any) =>
React.createElement('div', { 'data-testid': 'html-editor', 'data-name': name },
React.createElement('textarea', {
'data-testid': 'html-editor-textarea',
defaultValue: content ?? '',
placeholder,
})
)
);
if (extra) {
for (const [name, component] of Object.entries(extra)) {
registry.register('basic', name, component);
}
}
return registry;
}
// 플러그인 미설치: extension_point children 없음
const htmlEditorNoPluginLayout = {
version: '1.0.0',
layout_name: 'test_html_editor_no_plugin',
components: [
{
id: 'form_wrapper',
type: 'basic',
name: 'Div',
props: { 'data-testid': 'form-wrapper' },
children: [
{
id: 'content_editor',
type: 'extension_point',
name: 'html_editor',
props: {
name: 'content',
content: '{{_local.form?.content ?? ""}}',
isHtml: '{{(_local.form?.content_mode ?? "text") === "html"}}',
placeholder: '내용을 입력하세요',
rows: 15,
showHtmlModeToggle: true,
},
// children 없음 = 플러그인 미설치
},
],
},
],
};
// 플러그인 설치 시: 백엔드가 html-editor.json 컴포넌트를 children에 주입
const htmlEditorWithPluginLayout = {
version: '1.0.0',
layout_name: 'test_html_editor_with_plugin',
components: [
{
id: 'form_wrapper',
type: 'basic',
name: 'Div',
props: { 'data-testid': 'form-wrapper' },
children: [
{
id: 'content_editor',
type: 'extension_point',
name: 'html_editor',
props: { name: 'content', content: '' },
children: [
{
id: 'ckeditor5_container',
type: 'basic',
name: 'Div',
props: {
id: 'ckeditor5-content',
className: 'ckeditor5-wrapper',
'data-testid': 'ckeditor5-container',
},
},
],
},
],
},
],
};
// HtmlEditor 컴포넌트 직접 배치 (폴백 동작 검증)
const htmlEditorDirectLayout = {
version: '1.0.0',
layout_name: 'test_html_editor_direct',
components: [
{
id: 'html_editor_direct',
type: 'composite',
name: 'HtmlEditor',
props: {
name: 'content',
content: '{{_local.form?.content ?? ""}}',
isHtml: '{{(_local.form?.content_mode ?? "text") === "html"}}',
placeholder: '내용을 입력하세요',
rows: 15,
showHtmlModeToggle: true,
},
},
],
};
describe('html_editor extension_point — 플러그인 미설치', () => {
it('extension_point 컨테이너 div가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlEditorNoPluginLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
const container = document.getElementById('content_editor');
expect(container).toBeInTheDocument();
testUtils.cleanup();
});
it('플러그인 미설치 시 내부가 비어있다', async () => {
const testUtils = createLayoutTest(htmlEditorNoPluginLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
expect(screen.queryByTestId('html-editor')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('form_wrapper가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlEditorNoPluginLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
expect(screen.getByTestId('form-wrapper')).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('html_editor extension_point — 플러그인 설치 (백엔드 주입)', () => {
it('백엔드 주입된 CKEditor5 컨테이너가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlEditorWithPluginLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
expect(screen.getByTestId('ckeditor5-container')).toBeInTheDocument();
testUtils.cleanup();
});
it('extension_point id가 컨테이너 div에 적용된다', async () => {
const testUtils = createLayoutTest(htmlEditorWithPluginLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
const container = document.getElementById('content_editor');
expect(container).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('html_editor extension_point — HtmlEditor 폴백 직접 렌더링', () => {
it('HtmlEditor 컴포넌트가 렌더링된다', async () => {
const testUtils = createLayoutTest(htmlEditorDirectLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
expect(screen.getByTestId('html-editor')).toBeInTheDocument();
expect(screen.getByTestId('html-editor-textarea')).toBeInTheDocument();
testUtils.cleanup();
});
it('content가 비어있으면 textarea가 비어있다', async () => {
const testUtils = createLayoutTest(htmlEditorDirectLayout, {
componentRegistry: buildRegistry(),
});
await testUtils.render();
const textarea = screen.getByTestId('html-editor-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('');
testUtils.cleanup();
});
it('initialState의 form.content가 textarea에 반영된다', async () => {
const testUtils = createLayoutTest(htmlEditorDirectLayout, {
componentRegistry: buildRegistry(),
initialState: {
_local: { form: { content: '초기 내용입니다.', content_mode: 'text' } },
},
});
await testUtils.render();
const textarea = screen.getByTestId('html-editor-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('초기 내용입니다.');
testUtils.cleanup();
});
});
@@ -0,0 +1,59 @@
/**
* CKEditor5 인스턴스 정리 핸들러
*
* 컴포넌트 언마운트 시 CKEditor 인스턴스를 파괴합니다.
*/
// 컨테이너 ID → 인스턴스 맵 (initEditor.ts와 공유)
import { editorInstances } from './editorInstances';
import { disconnectDarkModeObserver } from './initEditor';
/**
* destroyEditor 핸들러
*
* onUnmount 시 해당 컨테이너의 모든 CKEditor 인스턴스를 파괴합니다.
*
* @param params 핸들러 파라미터 (사용 안 함)
* @param context 액션 컨텍스트
*/
export async function destroyEditorHandler(
action: Record<string, any>,
_context: unknown
): Promise<void> {
// ActionHandler 시그니처: (action: ActionDefinition, context: ActionContext)
// action.params.name 기반으로 containerId 계산 (initEditor와 동일한 패턴)
const editorName = (action.params?.name as string) ?? 'content';
const containerId = `ckeditor5-${editorName}`;
const instances = editorInstances.get(containerId);
if (!instances || instances.size === 0) {
return;
}
const destroyPromises: Promise<void>[] = [];
instances.forEach((editor) => {
if (editor && typeof editor.destroy === 'function') {
destroyPromises.push(
editor.destroy().catch((err: unknown) => {
console.warn('[sirsoft-ckeditor5] destroyEditor error:', err);
})
);
}
});
await Promise.allSettled(destroyPromises);
editorInstances.delete(containerId);
// height style 요소 정리
const heightStyle = document.getElementById(`ckeditor5-height-style-${containerId}`);
if (heightStyle) {
heightStyle.remove();
}
// 모든 에디터 인스턴스가 제거되면 다크모드 옵저버도 해제
if (editorInstances.size === 0) {
disconnectDarkModeObserver();
}
}
@@ -0,0 +1,18 @@
/**
* CKEditor5 인스턴스 공유 저장소
*
* containerId → locale별 에디터 인스턴스 맵을 관리합니다.
* initEditor / destroyEditor 핸들러가 공유합니다.
*/
// containerId → Map<locale, editorInstance>
export const editorInstances = new Map<string, Map<string, unknown>>();
// setData() 중 change:data → syncToForm 재진입 방지 플래그
let _syncSuppressed = false;
export function isSyncSuppressed(): boolean {
return _syncSuppressed;
}
export function setSyncSuppressed(value: boolean): void {
_syncSuppressed = value;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,161 @@
/**
* sirsoft-ckeditor5 플러그인 엔트리포인트
*
* 플러그인 활성화 시 자동으로 로드되어 핸들러를 등록합니다.
*/
import { initEditorHandler } from './handlers/initEditor';
import { destroyEditorHandler } from './handlers/destroyEditor';
const PLUGIN_IDENTIFIER = 'sirsoft-ckeditor5';
const CKEDITOR5_CSS_ID = 'ckeditor5-content-styles';
const CKEDITOR5_CSS_OVERRIDE_ID = 'ckeditor5-content-styles-override';
const CKEDITOR5_CSS_URL = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css';
/**
* CKEditor5 content styles CSS를 동적으로 주입하는 핸들러
* html-content.json extension_point의 onMount에서 호출됩니다.
*
* HtmlContent 컴포넌트에 ck-content 클래스가 적용되어 CDN CSS가 직접 동작합니다.
* 다크모드(CDN 미지원)와 col 너비 처리만 override로 추가합니다.
*/
function injectContentCssHandler(): void {
if (!document.getElementById(CKEDITOR5_CSS_ID)) {
const link = document.createElement('link');
link.id = CKEDITOR5_CSS_ID;
link.rel = 'stylesheet';
link.href = CKEDITOR5_CSS_URL;
document.head.appendChild(link);
}
if (!document.getElementById(CKEDITOR5_CSS_OVERRIDE_ID)) {
const style = document.createElement('style');
style.id = CKEDITOR5_CSS_OVERRIDE_ID;
// 다크모드(CDN 미지원)와 col 너비 처리만 override로 유지
style.textContent = `
/* 이미지 캡션 - 다크모드 */
html.dark .ck-content .image > figcaption { color: hsl(0, 0%, 80%); background-color: hsl(0, 0%, 15%); }
/* CKEditor5 표 스타일 - 다크모드 */
html.dark .ck-content figure.table table { border-color: hsl(0, 0%, 35%); }
html.dark .ck-content figure.table table tr { background-color: unset; }
html.dark .ck-content figure.table table td, html.dark .ck-content figure.table table th { border-color: hsl(0, 0%, 30%); color: hsl(0, 0%, 90%); background-color: unset; }
html.dark .ck-content figure.table table th { background-color: hsl(215, 20%, 25%); }
/* figure.table 전체 너비 - prose와의 충돌 방지 */
.ck-content figure.table { display: table; width: 100%; margin: 1em 0; }
.ck-content figure.table table { width: 100%; }
.ck-content table tr { background-color: unset; }
.ck-content table col { width: auto !important; }
/* 표 정렬 보정 */
.ck-content figure.table[style*="float:left"],
.ck-content figure.table[style*="float: left"] { margin-left: 0 !important; margin-right: 1em !important; }
.ck-content figure.table[style*="float:right"],
.ck-content figure.table[style*="float: right"] { margin-left: 1em !important; margin-right: 0 !important; }
/* 이미지 인라인/정렬 - Tailwind preflight img:block 대응 */
.ck-content img { display: inline; margin: 0; max-width: 100%; }
.ck-content p:has(img) { line-height: 0; }
.ck-content p:has(img[style*="float"]) { overflow: hidden; }
/* 이미지 캡션 - 라이트 모드 */
.ck-content .image > figcaption { background-color: #f3f4f6; color: #374151; font-size: 0.875em; padding: 4px 8px; text-align: center; }
/* 목록 마커 및 들여쓰기 - Tailwind preflight reset 대응 */
.ck-content ul { list-style-type: disc; padding-left: 2em; }
.ck-content ol { list-style-type: decimal; padding-left: 2em; }
.ck-content ul ul { list-style-type: circle; padding-left: 2em; }
.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(style);
}
}
const handlerMap: Record<string, Function> = {
initEditor: initEditorHandler,
destroyEditor: destroyEditorHandler,
injectContentCss: injectContentCssHandler,
};
const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {
log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
};
function registerHandlers(retry: boolean = false): void {
const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (actionDispatcher) {
Object.entries(handlerMap).forEach(([name, handler]) => {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
actionDispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`)
);
} else if (retry) {
let retryCount = 0;
const maxRetries = 50;
const retryRegister = () => {
const dispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (dispatcher) {
Object.entries(handlerMap).forEach(([name, handler]) => {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
dispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`)
);
} else {
retryCount++;
if (retryCount <= maxRetries) {
logger.warn(`ActionDispatcher not found, retrying... (${retryCount}/${maxRetries})`);
setTimeout(retryRegister, 100);
} else {
logger.error(`Failed to register handlers: ActionDispatcher not available after maximum retries`);
}
}
};
retryRegister();
} else {
logger.warn(`ActionDispatcher not found, handlers not registered`);
}
}
export function initPlugin(): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => registerHandlers(true));
} else {
const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();
registerHandlers(hasDispatcher ? false : true);
}
}
initPlugin();
if (typeof window !== 'undefined') {
(window as any).__SirsoftCkeditor5 = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
}
@@ -0,0 +1,86 @@
/**
* sirsoft-ckeditor5 플러그인 레이아웃 렌더링 테스트 환경 설정
* 코어 setup 패턴을 재사용합니다.
*/
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// 전역 mocks
global.fetch = vi.fn();
// node 환경에서는 window가 없으므로 jsdom 전용 설정만 실행
if (typeof window === 'undefined') {
// @vitest-environment node 테스트는 여기서 종료
} else {
/**
* window.matchMedia 모킹
*/
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
/**
* G7Core 전역 모킹
*/
(window as any).G7Core = {
createChangeEvent: vi.fn((data: { checked?: boolean; name?: string; value?: any }) => ({
target: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
currentTarget: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})),
state: {
get: vi.fn(),
set: vi.fn(),
update: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
modal: {
open: vi.fn(),
close: vi.fn(),
},
events: {
emit: vi.fn(),
on: vi.fn(() => vi.fn()),
off: vi.fn(),
},
t: (key: string, params?: Record<string, string | number>) => {
let translation = key;
if (params) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
translation = translation.replace(`{{${paramKey}}}`, String(paramValue));
});
}
return translation;
},
};
} // end of window check
@@ -0,0 +1,41 @@
{
"settings": {
"title": "CKEditor5 Settings",
"description": "CKEditor5 WYSIWYG editor plugin configuration",
"notice": "CKEditor5 is loaded via CDN. An internet connection is required. It is free to use without a separate license.",
"section_toolbar": "Editor Settings",
"section_image": "Image Settings",
"section_preview": "Editor Test",
"section_preview_hint": "Preview CKEditor5 with the current settings applied.",
"preview_placeholder": "Type here to test the editor.",
"fields": {
"toolbar": {
"label": "Toolbar Type",
"hint": "Select the toolbar button configuration for the editor.",
"minimal": "Minimal (Bold, Italic, Underline, Link)",
"standard": "Standard (Heading, Format, Alignment, Link, List, Table)",
"full": "Full (Standard + Media, Colors, Code Block, Source Editing)"
},
"image_upload": {
"label": "Enable Image Upload",
"hint": "Enable direct image upload in the editor. When disabled, only URL input is available."
},
"image_max_size": {
"label": "Max Image Size (MB)",
"hint": "Maximum allowed size for uploaded images. (1 ~ 10 MB)"
},
"editor_height": {
"label": "Editor Height (px)",
"hint": "Set the minimum height of the editor in pixels. (200 ~ 2000 px)"
}
},
"guide": {
"title": "Usage Guide",
"step1": "Once activated, CKEditor5 is automatically applied to areas using extension_point html_editor and html_content in layouts. Areas using HtmlEditor/HtmlContent components directly (e.g., mail templates) are not affected.",
"step2": "Select the toolbar type to adjust the features displayed in the editor. Choose a toolbar that fits your use case.",
"step3": "Enabling image upload allows users to paste or upload images directly in the editor. Uploaded images are stored on the server.",
"step4": "Changes take effect immediately after saving. No page refresh is required.",
"step5": "When deactivated, editors revert to the default HtmlEditor and content display reverts to the default HtmlContent."
}
}
}
@@ -0,0 +1,41 @@
{
"settings": {
"title": "CKEditor5 설정",
"description": "CKEditor5 WYSIWYG 에디터 플러그인 환경설정",
"notice": "CKEditor5는 CDN 방식으로 로드됩니다. 인터넷 연결이 필요하며, 별도의 라이선스 없이 무료로 사용할 수 있습니다.",
"section_toolbar": "에디터 설정",
"section_image": "이미지 설정",
"section_preview": "에디터 테스트",
"section_preview_hint": "현재 설정이 적용된 CKEditor5 에디터를 미리 확인할 수 있습니다.",
"preview_placeholder": "여기에 내용을 입력하여 에디터를 테스트해 보세요.",
"fields": {
"toolbar": {
"label": "툴바 유형",
"hint": "에디터에 표시할 툴바 버튼 구성을 선택합니다.",
"minimal": "최소 (굵게, 기울임, 밑줄, 링크)",
"standard": "표준 (제목, 서식, 정렬, 링크, 목록, 표)",
"full": "전체 (표준 + 미디어, 색상, 코드블록, 소스편집)"
},
"image_upload": {
"label": "이미지 업로드 사용",
"hint": "에디터 내 이미지 직접 업로드 기능을 활성화합니다. 비활성화 시 URL 입력만 가능합니다."
},
"image_max_size": {
"label": "최대 이미지 크기 (MB)",
"hint": "업로드 가능한 이미지의 최대 크기입니다. (1 ~ 10 MB)"
},
"editor_height": {
"label": "에디터 높이 (px)",
"hint": "에디터의 최소 높이를 픽셀 단위로 설정합니다. (200 ~ 2000 px)"
}
},
"guide": {
"title": "사용 안내",
"step1": "플러그인을 활성화하면 레이아웃에서 extension_point로 html_editor, html_content를 사용하는 영역에 CKEditor5가 자동 적용됩니다. 직접 HtmlEditor/HtmlContent 컴포넌트를 사용하는 영역(예: 메일 템플릿)에는 영향을 주지 않습니다.",
"step2": "툴바 유형을 선택하여 에디터에 표시할 기능을 조정합니다. 운영 목적에 맞는 툴바를 선택하세요.",
"step3": "이미지 업로드를 활성화하면 에디터에서 이미지를 직접 붙여넣거나 업로드할 수 있습니다. 업로드된 이미지는 서버에 저장됩니다.",
"step4": "설정 변경 후 저장하면 즉시 모든 에디터에 반영됩니다.",
"step5": "플러그인을 비활성화하면 에디터는 기본 HtmlEditor로, 콘텐츠 표시는 기본 HtmlContent로 자동 복원됩니다."
}
}
}
@@ -0,0 +1,669 @@
{
"version": "1.0.0",
"layout_name": "plugin_settings",
"permissions": [
"core.plugins.update"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-ckeditor5.settings.title",
"description": "$t:sirsoft-ckeditor5.settings.description"
},
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true
}
],
"slots": {
"content": [
{
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900 min-h-screen"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-ckeditor5.settings.title",
"description": "$t:sirsoft-ckeditor5.settings.description",
"backPath": "/admin/plugins",
"backLabel": "$t:admin.plugins.back_to_list"
}
},
{
"id": "notice_banner",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"size": "lg",
"className": "text-blue-600 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-blue-700 dark:text-blue-300" },
"text": "$t:sirsoft-ckeditor5.settings.notice"
}
]
}
]
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mt-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"size": "lg",
"className": "text-red-600 dark:text-red-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" },
"text": "$t:admin.plugins.settings.validation_error"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": { "className": "mt-2 list-disc list-inside space-y-1" },
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-red-700 dark:text-red-300" },
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "section_toolbar_card",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "section_toolbar_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"text": "$t:sirsoft-ckeditor5.settings.section_toolbar"
}
]
},
{
"id": "section_toolbar_fields",
"type": "basic",
"name": "Div",
"props": { "className": "p-6 space-y-6" },
"children": [
{
"id": "field_toolbar",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-2" },
"children": [
{
"type": "basic",
"name": "Select",
"props": {
"name": "toolbar",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
},
"children": [
{
"type": "basic",
"name": "Option",
"props": { "value": "minimal" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.minimal"
},
{
"type": "basic",
"name": "Option",
"props": { "value": "standard" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.standard"
},
{
"type": "basic",
"name": "Option",
"props": { "value": "full" },
"text": "$t:sirsoft-ckeditor5.settings.fields.toolbar.full"
}
]
}
]
}
]
},
{
"id": "field_editor_height",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"text": "$t:sirsoft-ckeditor5.settings.fields.editor_height.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.fields.editor_height.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-2" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "editorHeight",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
}
}
]
}
]
}
]
}
]
},
{
"id": "section_image_card",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "section_image_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"text": "$t:sirsoft-ckeditor5.settings.section_image"
}
]
},
{
"id": "section_image_fields",
"type": "basic",
"name": "Div",
"props": { "className": "p-6 space-y-6" },
"children": [
{
"id": "field_image_upload",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_upload.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_upload.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-2" },
"children": [
{
"type": "basic",
"name": "Toggle",
"props": { "name": "imageUpload" }
}
]
}
]
},
{
"id": "field_image_max_size",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-3 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_max_size.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.fields.image_max_size.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-2" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "imageMaxSizeMb",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
}
}
]
}
]
}
]
}
]
},
{
"id": "guide_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-lg font-semibold text-gray-900 dark:text-white mb-4" },
"text": "$t:sirsoft-ckeditor5.settings.guide.title"
},
{
"type": "basic",
"name": "Ol",
"props": { "className": "list-decimal list-inside space-y-3" },
"children": [
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step1"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step2"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step3"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step4"
},
{
"type": "basic",
"name": "Li",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.guide.step5"
}
]
}
]
},
{
"id": "section_preview_card",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "section_preview_header",
"type": "basic",
"name": "Div",
"props": { "className": "px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" },
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"showPreview": "{{!_local.showPreview}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "text-base font-semibold text-gray-900 dark:text-white" },
"text": "$t:sirsoft-ckeditor5.settings.section_preview"
},
{
"type": "basic",
"name": "P",
"props": { "className": "mt-1 text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-ckeditor5.settings.section_preview_hint"
}
]
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{_local.showPreview ? 'chevron-up' : 'chevron-down'}}",
"className": "text-gray-400 dark:text-gray-500"
}
}
]
},
{
"id": "section_preview_body",
"type": "basic",
"name": "Div",
"if": "{{_local.showPreview}}",
"props": { "className": "p-6 border-t border-gray-200 dark:border-gray-700" },
"children": [
{
"id": "preview_editor",
"type": "extension_point",
"name": "html_editor",
"props": {
"name": "preview_content",
"content": "",
"placeholder": "$t:sirsoft-ckeditor5.settings.preview_placeholder",
"multilingual": true
},
"default": [
{
"type": "composite",
"name": "HtmlEditor",
"props": {
"name": "preview_content",
"content": "{{extensionPointProps.content ?? ''}}",
"multilingual": "{{extensionPointProps.multilingual ?? false}}",
"placeholder": "{{extensionPointProps.placeholder ?? ''}}",
"isHtml": true,
"rows": 15,
"showHtmlModeToggle": false
}
}
]
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end gap-3 mt-6" },
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": { "path": "/admin/plugins" }
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/plugins/{{route.identifier}}/settings",
"params": {
"method": "PUT",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "setState",
"params": {
"target": "global",
"plugins": "{{Object.assign({}, _global.plugins ?? {}, { 'sirsoft-ckeditor5': _local.form })}}"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.plugins.settings.save_success"
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "settings" }
},
{
"handler": "remount",
"params": { "componentId": "preview_editor" }
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.plugins.settings.save_error"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": { "name": "spinner", "size": "sm", "className": "animate-spin" }
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isSaving}}",
"text": "$t:common.save"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isSaving}}",
"text": "$t:common.saving"
}
]
}
]
}
]
}
]
}
}
@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"routes": []
}
@@ -0,0 +1,45 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Http\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* CKEditor5 이미지 서빙 컨트롤러
*
* plugins 디스크에 저장된 이미지를 공개 API를 통해 서빙합니다.
* 인증 없이 접근 가능한 공개 엔드포인트입니다.
*/
class ImageServeController extends PublicBaseController
{
public function __construct(
private ImageServeService $imageServeService
) {}
/**
* 이미지 서빙
*
* @param string $hash 이미지 해시 (12자)
* @return StreamedResponse|JsonResponse 이미지 스트림 또는 에러 응답
*/
public function serve(string $hash): StreamedResponse|JsonResponse
{
$image = $this->imageServeService->findByHash($hash);
if (! $image) {
return ResponseHelper::notFound('messages.image.not_found', [], 'sirsoft-ckeditor5');
}
$response = $this->imageServeService->serve($image);
if (! $response) {
return ResponseHelper::notFound('messages.image.not_found', [], 'sirsoft-ckeditor5');
}
return $response;
}
}
@@ -0,0 +1,67 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Http\Controllers;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\Ckeditor5\Http\Requests\ImageUploadRequest;
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService;
/**
* CKEditor5 이미지 업로드 컨트롤러
*
* CKEditor5 SimpleUploadAdapter가 요구하는 형식으로 이미지를 저장하고
* 공개 URL을 반환합니다.
*
* 응답 형식: { "url": "https://..." }
* ResponseHelper 사용 불가 — CKEditor SimpleUploadAdapter는 최상위 { url } 키를 요구합니다.
*/
class ImageUploadController extends AdminBaseController
{
public function __construct(
private ImageUploadService $imageUploadService
) {
parent::__construct();
}
/**
* 이미지를 업로드하고 공개 URL을 반환합니다.
*
* CKEditor5 SimpleUploadAdapter 규격:
* - 성공: HTTP 201, { "url": "..." }
* - 실패: HTTP 4xx/5xx, { "error": { "message": "..." } }
*
* @param ImageUploadRequest $request 업로드 요청
* @return JsonResponse
*/
public function upload(ImageUploadRequest $request): JsonResponse
{
$uploadPermission = $request->query('permission', '');
$user = $this->getCurrentUser();
if ($uploadPermission && $user && ! $user->hasPermission($uploadPermission)) {
return response()->json([
'error' => ['message' => __('sirsoft-ckeditor5::messages.upload.forbidden')],
], 403);
}
try {
$image = $this->imageUploadService->upload(
$request->file('upload'),
$user?->id
);
// CKEditor SimpleUploadAdapter가 요구하는 최상위 { url } 형식
return response()->json(['url' => $image->download_url], 201);
} catch (\Exception $e) {
Log::error('[sirsoft-ckeditor5] 이미지 업로드 실패', [
'error' => $e->getMessage(),
]);
return response()->json([
'error' => ['message' => __('sirsoft-ckeditor5::messages.upload.failed')],
], 500);
}
}
}
@@ -0,0 +1,86 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
/**
* CKEditor5 이미지 업로드 요청 검증
*
* CKEditor5 SimpleUploadAdapter가 전송하는 이미지 파일을 검증합니다.
*/
class ImageUploadRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어 체인에서 처리)
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var int $maxSizeMb 최대 파일 크기 (MB) */
$maxSizeMb = (int) plugin_setting('sirsoft-ckeditor5', 'imageMaxSizeMb', 2);
/** @var int $maxSizeKb Laravel max 규칙은 KB 단위 */
$maxSizeKb = $maxSizeMb * 1024;
return [
'upload' => [
'required',
'file',
'image',
'mimes:jpeg,jpg,png,gif,webp',
"max:{$maxSizeKb}",
],
];
}
/**
* 검증 오류 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
$maxSizeMb = (int) plugin_setting('sirsoft-ckeditor5', 'imageMaxSizeMb', 2);
return [
'upload.required' => __('sirsoft-ckeditor5::messages.upload.required'),
'upload.file' => __('sirsoft-ckeditor5::messages.upload.invalid_file'),
'upload.image' => __('sirsoft-ckeditor5::messages.upload.not_image'),
'upload.mimes' => __('sirsoft-ckeditor5::messages.upload.invalid_mime'),
'upload.max' => __('sirsoft-ckeditor5::messages.upload.too_large', ['max' => $maxSizeMb]),
];
}
/**
* 검증 실패 시 CKEditor SimpleUploadAdapter 규격으로 응답합니다.
*
* Laravel 기본 응답: { message, errors }
* CKEditor 요구 형식: { error: { message } }
*
* @param Validator $validator
* @return never
*/
protected function failedValidation(Validator $validator): never
{
$firstError = collect($validator->errors()->all())->first()
?? __('sirsoft-ckeditor5::messages.upload.failed');
throw new HttpResponseException(
response()->json(['error' => ['message' => $firstError]], 422)
);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\User;
/**
* CKEditor5 이미지 업로드 기록 모델
*
* @property int $id
* @property string $hash URL용 고유 해시 (12자)
* @property string $original_name 원본 파일명
* @property string $file_path 저장 파일 경로
* @property string $storage_disk 스토리지 디스크
* @property int $file_size 파일 크기 (bytes)
* @property string $mime_type MIME 타입
* @property int|null $uploaded_by 업로드 사용자 ID
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* @property-read string $download_url 이미지 서빙 URL
*/
class Ckeditor5ImageUpload extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'ckeditor5_image_uploads';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'hash',
'original_name',
'file_path',
'storage_disk',
'file_size',
'mime_type',
'uploaded_by',
];
/**
* 모델 부트 메서드
*/
protected static function boot(): void
{
parent::boot();
static::creating(function (self $model) {
if (empty($model->hash)) {
$model->hash = self::generateHash();
}
});
}
/**
* 고유 해시를 생성합니다.
*
* @return string 12자리 고유 해시
*/
public static function generateHash(): string
{
do {
$hash = substr(bin2hex(random_bytes(6)), 0, 12);
} while (self::where('hash', $hash)->exists());
return $hash;
}
/**
* 이미지 서빙 URL 반환 (API 라우트 경유)
*
* @return string 이미지 서빙 URL
*/
public function getDownloadUrlAttribute(): string
{
return '/api/plugins/sirsoft-ckeditor5/images/'.$this->hash;
}
/**
* 업로드한 사용자
*
* @return BelongsTo<User, Ckeditor5ImageUpload>
*/
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
}
@@ -0,0 +1,46 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Providers;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\PluginStorageDriver;
use Illuminate\Support\ServiceProvider;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
use Plugins\Sirsoft\Ckeditor5\Repositories\ImageUploadRepository;
use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService;
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService;
/**
* CKEditor5 플러그인 서비스 프로바이더
*
* Repository 인터페이스와 구현체 바인딩,
* StorageInterface 주입을 담당합니다.
*/
class Ckeditor5ServiceProvider extends ServiceProvider
{
/**
* 플러그인 식별자
*/
private const PLUGIN_ID = 'sirsoft-ckeditor5';
/**
* 서비스 컨테이너 바인딩 등록
*
* @return void
*/
public function register(): void
{
// Repository 바인딩
$this->app->bind(
ImageUploadRepositoryInterface::class,
ImageUploadRepository::class
);
// StorageInterface 바인딩 — plugins 디스크 사용
$this->app->when([ImageUploadService::class, ImageServeService::class])
->needs(StorageInterface::class)
->give(function () {
return new PluginStorageDriver(self::PLUGIN_ID, 'plugins');
});
}
}
@@ -0,0 +1,27 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Repositories\Contracts;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
/**
* CKEditor5 이미지 업로드 Repository 인터페이스
*/
interface ImageUploadRepositoryInterface
{
/**
* 해시로 이미지 조회
*
* @param string $hash 이미지 해시 (12자)
* @return Ckeditor5ImageUpload|null
*/
public function findByHash(string $hash): ?Ckeditor5ImageUpload;
/**
* 이미지 기록 생성
*
* @param array $data 이미지 데이터
* @return Ckeditor5ImageUpload
*/
public function create(array $data): Ckeditor5ImageUpload;
}
@@ -0,0 +1,32 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Repositories;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
/**
* CKEditor5 이미지 업로드 Repository 구현체
*/
class ImageUploadRepository implements ImageUploadRepositoryInterface
{
public function __construct(
protected Ckeditor5ImageUpload $model
) {}
/**
* {@inheritDoc}
*/
public function findByHash(string $hash): ?Ckeditor5ImageUpload
{
return $this->model->where('hash', $hash)->first();
}
/**
* {@inheritDoc}
*/
public function create(array $data): Ckeditor5ImageUpload
{
return $this->model->create($data);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Services;
use App\Contracts\Extension\StorageInterface;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* CKEditor5 이미지 서빙 서비스
*
* 업로드된 이미지를 hash로 조회하여 StreamedResponse를 반환합니다.
*/
class ImageServeService
{
/**
* @param ImageUploadRepositoryInterface $repository 이미지 업로드 리포지토리
* @param StorageInterface $storage 플러그인 스토리지 드라이버
*/
public function __construct(
protected ImageUploadRepositoryInterface $repository,
protected StorageInterface $storage
) {}
/**
* hash로 이미지 조회
*
* @param string $hash 이미지 해시 (12자)
* @return Ckeditor5ImageUpload|null
*/
public function findByHash(string $hash): ?Ckeditor5ImageUpload
{
return $this->repository->findByHash($hash);
}
/**
* 이미지 스트림 응답 생성
*
* @param Ckeditor5ImageUpload $image 이미지 업로드 모델
* @return StreamedResponse|null 이미지 스트림 또는 스토리지 파일 없을 경우 null
*/
public function serve(Ckeditor5ImageUpload $image): ?StreamedResponse
{
// file_path 형태: "images/2026/04/06/{uuid}.jpg"
// PluginStorageDriver::response(category, path) 에서 category = 'images'
$category = 'images';
$relativePath = substr($image->file_path, strlen($category) + 1);
$response = $this->storage->response(
$category,
$relativePath,
$image->original_name,
[
'Content-Type' => $image->mime_type,
'Cache-Control' => 'public, max-age=31536000',
]
);
if (! $response) {
Log::error('CKEditor5 이미지 스토리지에 없음', [
'image_id' => $image->id,
'hash' => $image->hash,
'file_path' => $image->file_path,
'disk' => $image->storage_disk,
]);
return null;
}
return $response;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Services;
use App\Contracts\Extension\StorageInterface;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
/**
* CKEditor5 이미지 업로드 서비스
*
* 이미지를 plugins 디스크에 저장하고 업로드 기록을 생성합니다.
*/
class ImageUploadService
{
/**
* @param ImageUploadRepositoryInterface $repository 이미지 업로드 리포지토리
* @param StorageInterface $storage 플러그인 스토리지 드라이버
*/
public function __construct(
protected ImageUploadRepositoryInterface $repository,
protected StorageInterface $storage
) {}
/**
* 이미지를 업로드하고 기록을 저장합니다.
*
* @param UploadedFile $file 업로드된 파일
* @param int|null $uploadedBy 업로드 사용자 ID
* @return Ckeditor5ImageUpload 생성된 업로드 기록
*/
public function upload(UploadedFile $file, ?int $uploadedBy): Ckeditor5ImageUpload
{
$extension = $file->guessExtension() ?? $file->getClientOriginalExtension();
$storedFilename = Str::uuid().'.'.$extension;
$path = date('Y/m/d').'/'.$storedFilename;
$this->storage->put('images', $path, file_get_contents($file->getRealPath()));
return $this->repository->create([
'original_name' => $file->getClientOriginalName(),
'file_path' => "images/{$path}",
'storage_disk' => $this->storage->getDisk(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_by' => $uploadedBy,
]);
}
}
@@ -0,0 +1,23 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageServeController;
use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageUploadController;
/*
* sirsoft-ckeditor5 플러그인 API 라우트
*
* URL prefix: /api/plugins/sirsoft-ckeditor5 (PluginRouteServiceProvider 자동 적용)
*
* 인증 라우트: AdminBaseController에서 auth:sanctum + admin 미들웨어 적용
* 공개 라우트: PublicBaseController 사용, 인증 불필요
*/
// 이미지 업로드 (관리자 인증 필요)
Route::post('upload', [ImageUploadController::class, 'upload'])
->name('api.sirsoft-ckeditor5.upload');
// 이미지 서빙 (공개 접근 — CKEditor 에디터 내 <img src> 직접 접근)
Route::get('images/{hash}', [ImageServeController::class, 'serve'])
->where('hash', '[a-f0-9]{12}')
->name('api.sirsoft-ckeditor5.images.serve');
@@ -0,0 +1,90 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Http\Controllers;
use Illuminate\Support\Facades\Storage;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
/**
* CKEditor5 이미지 서빙 API 테스트
*
* GET /api/plugins/sirsoft-ckeditor5/images/{hash}
*/
class ImageServeControllerTest extends PluginTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('plugins');
}
// ── 성공 ──
public function test_serve_returns_image_for_valid_hash(): void
{
// 파일을 fake 스토리지에 저장
$filePath = 'sirsoft-ckeditor5/images/2026/04/06/test.jpg';
Storage::disk('plugins')->put($filePath, 'fake-image-content');
$record = Ckeditor5ImageUpload::create([
'original_name' => 'test.jpg',
'file_path' => 'images/2026/04/06/test.jpg',
'storage_disk' => 'plugins',
'file_size' => 100,
'mime_type' => 'image/jpeg',
'uploaded_by' => null,
]);
$response = $this->get('/api/plugins/sirsoft-ckeditor5/images/'.$record->hash);
$response->assertOk();
$response->assertHeader('Content-Type', 'image/jpeg');
}
public function test_serve_is_accessible_without_authentication(): void
{
$filePath = 'sirsoft-ckeditor5/images/2026/04/06/public.jpg';
Storage::disk('plugins')->put($filePath, 'fake-image-content');
$record = Ckeditor5ImageUpload::create([
'original_name' => 'public.jpg',
'file_path' => 'images/2026/04/06/public.jpg',
'storage_disk' => 'plugins',
'file_size' => 100,
'mime_type' => 'image/jpeg',
'uploaded_by' => null,
]);
// 인증 없이 접근
$response = $this->get('/api/plugins/sirsoft-ckeditor5/images/'.$record->hash);
$response->assertOk();
}
// ── 실패 ──
public function test_serve_returns_404_for_unknown_hash(): void
{
$response = $this->get('/api/plugins/sirsoft-ckeditor5/images/unknown00000');
$response->assertNotFound();
}
public function test_serve_returns_404_when_file_missing_from_storage(): void
{
// DB 레코드는 있지만 실제 파일은 없는 경우
$record = Ckeditor5ImageUpload::create([
'original_name' => 'missing.jpg',
'file_path' => 'images/2026/04/06/missing.jpg',
'storage_disk' => 'plugins',
'file_size' => 100,
'mime_type' => 'image/jpeg',
'uploaded_by' => null,
]);
$response = $this->get('/api/plugins/sirsoft-ckeditor5/images/'.$record->hash);
$response->assertNotFound();
}
}
@@ -0,0 +1,116 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Http\Controllers;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Plugins\Sirsoft\Ckeditor5\Models\Ckeditor5ImageUpload;
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
/**
* CKEditor5 이미지 업로드 API 테스트
*
* POST /api/plugins/sirsoft-ckeditor5/upload
*/
class ImageUploadControllerTest extends PluginTestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('plugins');
}
// ── 인증 ──
public function test_upload_requires_authentication(): void
{
$file = UploadedFile::fake()->image('test.jpg');
$response = $this->postJson('/api/plugins/sirsoft-ckeditor5/upload', [
'upload' => $file,
]);
$response->assertUnauthorized();
}
// ── 성공 ──
public function test_upload_succeeds_for_admin(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->image('photo.jpg', 100, 100)->size(500);
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$response->assertStatus(201);
$response->assertJsonStructure(['url']);
$this->assertStringContainsString('/api/plugins/sirsoft-ckeditor5/images/', $response->json('url'));
}
public function test_upload_stores_record_in_database(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->image('photo.png')->size(200);
$this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$this->assertDatabaseCount('ckeditor5_image_uploads', 1);
$record = Ckeditor5ImageUpload::first();
$this->assertNotNull($record->hash);
$this->assertEquals(12, strlen($record->hash));
$this->assertEquals('photo.png', $record->original_name);
$this->assertEquals('plugins', $record->storage_disk);
$this->assertStringStartsWith('images/', $record->file_path);
$this->assertEquals($admin->id, $record->uploaded_by);
}
public function test_upload_returns_correct_url_format(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->image('test.jpg');
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$url = $response->json('url');
$record = Ckeditor5ImageUpload::first();
$this->assertEquals('/api/plugins/sirsoft-ckeditor5/images/'.$record->hash, $url);
}
// ── 검증 ──
public function test_upload_rejects_non_image_file(): void
{
$admin = $this->createAdminUser();
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
['upload' => $file]
);
$response->assertUnprocessable();
}
public function test_upload_rejects_missing_file(): void
{
$admin = $this->createAdminUser();
$response = $this->actingAs($admin)->postJson(
'/api/plugins/sirsoft-ckeditor5/upload',
[]
);
$response->assertUnprocessable();
}
}
@@ -0,0 +1,105 @@
<?php
namespace Plugins\Sirsoft\Ckeditor5\Tests;
use App\Contracts\Extension\StorageInterface;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Extension\Storage\PluginStorageDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageServeController;
use Plugins\Sirsoft\Ckeditor5\Http\Controllers\ImageUploadController;
use Plugins\Sirsoft\Ckeditor5\Repositories\Contracts\ImageUploadRepositoryInterface;
use Plugins\Sirsoft\Ckeditor5\Repositories\ImageUploadRepository;
use Plugins\Sirsoft\Ckeditor5\Services\ImageServeService;
use Plugins\Sirsoft\Ckeditor5\Services\ImageUploadService;
use Tests\TestCase;
/**
* CKEditor5 플러그인 테스트 베이스 클래스
*
* 모든 CKEditor5 플러그인 테스트는 이 클래스를 상속받아야 합니다.
* 코어 + 플러그인 마이그레이션 및 라우트를 자동으로 처리합니다.
*/
abstract class PluginTestCase extends TestCase
{
use RefreshDatabase;
/**
* 테스트 환경 설정
*
* 플러그인 라우트와 바인딩을 테스트 환경에서 직접 등록합니다.
*/
protected function setUp(): void
{
parent::setUp();
// Repository 바인딩
$this->app->bind(
ImageUploadRepositoryInterface::class,
ImageUploadRepository::class
);
// StorageInterface 바인딩
$this->app->when([ImageUploadService::class, ImageServeService::class])
->needs(StorageInterface::class)
->give(fn () => new PluginStorageDriver('sirsoft-ckeditor5', 'plugins'));
// 라우트 등록
Route::prefix('api/plugins/sirsoft-ckeditor5')
->middleware('api')
->group(function () {
Route::post('upload', [ImageUploadController::class, 'upload'])
->name('api.sirsoft-ckeditor5.upload');
Route::get('images/{hash}', [ImageServeController::class, 'serve'])
->name('api.sirsoft-ckeditor5.images.serve');
});
}
/**
* 관리자 권한을 가진 사용자를 생성합니다.
*
* @return User
*/
protected function createAdminUser(): User
{
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
['name' => ['ko' => '관리자', 'en' => 'Admin'], 'description' => ['ko' => '관리자', 'en' => 'Admin']]
);
$permission = Permission::firstOrCreate(
['identifier' => 'admin.access'],
['name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'], 'type' => PermissionType::Admin]
);
$adminRole->permissions()->syncWithoutDetaching([$permission->id]);
$user = User::factory()->create();
$user->roles()->attach($adminRole->id);
return $user;
}
/**
* 마이그레이션 경로를 반환합니다.
*
* @return array
*/
protected function migrateFreshUsing(): array
{
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => [
'database/migrations',
'plugins/_bundled/sirsoft-ckeditor5/database/migrations',
],
];
}
}
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["resources/js/*"]
}
},
"include": ["resources/js/**/*.ts"]
}
@@ -0,0 +1,38 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
lib: {
entry: path.resolve(__dirname, 'resources/js/index.ts'),
name: 'SirsoftCkeditor5',
fileName: 'plugin',
formats: ['iife'],
},
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'js/plugin.iife.js',
chunkFileNames: 'js/[name]-[hash].js',
},
},
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
});
@@ -0,0 +1,31 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
import fs from 'fs';
// 프로젝트 루트를 동적으로 탐색 (artisan 파일 기준)
function findProjectRoot(startDir: string): string {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
dir = path.dirname(dir);
}
return path.resolve(startDir, '../../'); // fallback
}
const projectRoot = findProjectRoot(__dirname);
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules/', 'dist/'],
setupFiles: ['./resources/js/tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
'@core': path.resolve(projectRoot, 'resources/js/core'),
},
},
});
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-04-20
### Changed
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
- extension JSON: `extensionPointProps.onAddressSelect` → `extensionPointCallbacks.onAddressSelect` 참조 변경 (extension_point props/callbacks 분리)
## [1.0.0-beta.1] - 2026-04-01
### Changed
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-daum_postcode",
"description": "Daum Postcode Service Plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"authors": [
{
"name": "sirsoft",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-daum_postcode",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"description": "그누보드7 Daum 우편번호 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -5,13 +5,13 @@
"ko": "Daum 우편번호",
"en": "Daum Postcode"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"description": {
"ko": "Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.",
"en": "Plugin that provides address search functionality through Daum Postcode service. Free to use without API key."
},
"g7_version": ">=7.0.0-beta.1",
"g7_version": ">=7.0.0-beta.2",
"dependencies": {
"modules": {},
"plugins": {}
@@ -49,7 +49,7 @@
"searchBgColor": "{{_global.plugins['sirsoft-daum_postcode']?.theme_color ?? '#1D4ED8'}}",
"queryTextColor": "#FFFFFF"
},
"callbackAction": "{{extensionPointProps.onAddressSelect}}",
"callbackAction": "{{extensionPointCallbacks.onAddressSelect}}",
"targetFields": "{{extensionPointProps.targetFields}}"
}
}
@@ -4,6 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-04-20
### Changed
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
- sirsoft-page 의존성 버전 제약을 실제 릴리스 버전에 맞춰 정비
- 플러그인 의존성 관리를 manifest(plugin.json) 기준으로 일원화
## [1.0.0-beta.1] - 2026-04-01
### Changed
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-marketing",
"description": "Marketing consent and subscription management plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Marketing\\": ["src/", "./"]
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-marketing",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -5,16 +5,16 @@
"ko": "마케팅 동의",
"en": "Marketing Consent"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"description": {
"ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인",
"en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent"
},
"license": "MIT",
"g7_version": ">=7.0.0-beta.1",
"g7_version": ">=7.0.0-beta.2",
"dependencies": {
"modules": {
"sirsoft-page": ">=0.1.0"
"sirsoft-page": ">=1.0.0-beta.2"
},
"plugins": {}
},
@@ -182,20 +182,6 @@ class Plugin extends AbstractPlugin
];
}
/**
* 플러그인 의존성 반환
*
* sirsoft-page 모듈이 필요합니다 (약관 페이지 slug 연동).
*
* @return array 모듈/플러그인 의존성 배열
*/
public function getDependencies(): array
{
return [
'sirsoft-page' => '>=0.1.0',
];
}
/**
* 훅 리스너 목록 반환
*
@@ -4,6 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-04-20
### Changed
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
- sirsoft-ecommerce 의존성 버전 제약을 실제 릴리스 버전에 맞춰 정비
- 플러그인 의존성 관리를 manifest(plugin.json) 기준으로 일원화
## [1.0.0-beta.1] - 2026-04-01
### Changed
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-tosspayments",
"description": "TossPayments PG Plugin for G7 platform",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"type": "library",
"authors": [
{
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-tosspayments",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"type": "module",
"private": true,
"scripts": {
@@ -5,16 +5,16 @@
"ko": "토스페이먼츠",
"en": "TossPayments"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"description": {
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
"en": "TossPayments gateway (integrated payment window)"
},
"g7_version": ">=7.0.0-beta.1",
"g7_version": ">=7.0.0-beta.2",
"dependencies": {
"modules": {
"sirsoft-ecommerce": ">=0.1.1"
"sirsoft-ecommerce": ">=1.0.0-beta.2"
},
"plugins": {}
},
@@ -27,18 +27,6 @@ class Plugin extends AbstractPlugin
];
}
/**
* 플러그인 의존성 반환
*
* @return array 의존하는 모듈 목록
*/
public function getDependencies(): array
{
return [
'sirsoft-ecommerce' => '>=1.0.0',
];
}
/**
* 환경설정 스키마 반환
*