v7.0.0-beta.2 release
This commit is contained in:
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 }
|
||||
}
|
||||
}
|
||||
+45
@@ -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' => '이미지 업로드에 실패했습니다.',
|
||||
],
|
||||
];
|
||||
+1002
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
|
||||
}
|
||||
+313
@@ -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();
|
||||
});
|
||||
});
|
||||
+225
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -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');
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+116
@@ -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": {}
|
||||
|
||||
+1
-1
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경설정 스키마 반환
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user