podo-ui 0.9.7 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cdn/podo-datepicker.css +1 -1
- package/cdn/podo-datepicker.js +1 -1
- package/cdn/podo-datepicker.min.css +1 -1
- package/cdn/podo-datepicker.min.js +1 -1
- package/cdn/podo-ui.css +4 -1
- package/cdn/podo-ui.min.css +1 -1
- package/dist/react/atom/editor.d.ts.map +1 -1
- package/dist/react/atom/editor.js +94 -2
- package/dist/svelte/actions/portal.d.ts +18 -0
- package/dist/svelte/actions/portal.js +42 -0
- package/dist/svelte/atom/Avatar.svelte +97 -0
- package/dist/svelte/atom/Avatar.svelte.d.ts +31 -0
- package/dist/svelte/atom/Button.svelte +86 -0
- package/dist/svelte/atom/Button.svelte.d.ts +26 -0
- package/dist/svelte/atom/Checkbox.svelte +56 -0
- package/dist/svelte/atom/Checkbox.svelte.d.ts +16 -0
- package/dist/svelte/atom/Chip.svelte +60 -0
- package/dist/svelte/atom/Chip.svelte.d.ts +25 -0
- package/dist/svelte/atom/Editor.svelte +1314 -0
- package/dist/svelte/atom/Editor.svelte.d.ts +30 -0
- package/dist/svelte/atom/EditorView.svelte +16 -0
- package/dist/svelte/atom/EditorView.svelte.d.ts +9 -0
- package/dist/svelte/atom/File.svelte +33 -0
- package/dist/svelte/atom/File.svelte.d.ts +14 -0
- package/dist/svelte/atom/Input.svelte +80 -0
- package/dist/svelte/atom/Input.svelte.d.ts +19 -0
- package/dist/svelte/atom/Label.svelte +43 -0
- package/dist/svelte/atom/Label.svelte.d.ts +19 -0
- package/dist/svelte/atom/Radio.svelte +69 -0
- package/dist/svelte/atom/Radio.svelte.d.ts +26 -0
- package/dist/svelte/atom/RadioGroup.svelte +46 -0
- package/dist/svelte/atom/RadioGroup.svelte.d.ts +16 -0
- package/dist/svelte/atom/Select.svelte +65 -0
- package/dist/svelte/atom/Select.svelte.d.ts +26 -0
- package/dist/svelte/atom/Textarea.svelte +53 -0
- package/dist/svelte/atom/Textarea.svelte.d.ts +13 -0
- package/dist/svelte/atom/Toggle.svelte +48 -0
- package/dist/svelte/atom/Toggle.svelte.d.ts +14 -0
- package/dist/svelte/atom/Tooltip.svelte +78 -0
- package/dist/svelte/atom/Tooltip.svelte.d.ts +23 -0
- package/dist/svelte/atom/avatar.module.scss +82 -0
- package/dist/svelte/atom/editor-view.module.scss +251 -0
- package/dist/svelte/atom/input.module.scss +98 -0
- package/dist/svelte/atom/textarea.module.scss +17 -0
- package/dist/svelte/atom/tooltip.module.scss +227 -0
- package/dist/svelte/index.d.ts +26 -0
- package/dist/svelte/index.js +30 -0
- package/dist/svelte/molecule/DatePicker.svelte +986 -0
- package/dist/svelte/molecule/DatePicker.svelte.d.ts +71 -0
- package/dist/svelte/molecule/Field.svelte +81 -0
- package/dist/svelte/molecule/Field.svelte.d.ts +26 -0
- package/dist/svelte/molecule/Pagination.svelte +95 -0
- package/dist/svelte/molecule/Pagination.svelte.d.ts +14 -0
- package/dist/svelte/molecule/Tab.svelte +69 -0
- package/dist/svelte/molecule/Tab.svelte.d.ts +26 -0
- package/dist/svelte/molecule/TabPanel.svelte +24 -0
- package/dist/svelte/molecule/TabPanel.svelte.d.ts +14 -0
- package/dist/svelte/molecule/Table.svelte +109 -0
- package/dist/svelte/molecule/Table.svelte.d.ts +54 -0
- package/dist/svelte/molecule/Toast.svelte +111 -0
- package/dist/svelte/molecule/Toast.svelte.d.ts +25 -0
- package/dist/svelte/molecule/ToastProvider.svelte +74 -0
- package/dist/svelte/molecule/ToastProvider.svelte.d.ts +8 -0
- package/dist/svelte/molecule/field.module.scss +22 -0
- package/dist/svelte/molecule/pagination.module.scss +61 -0
- package/dist/svelte/molecule/toast-container.module.scss +70 -0
- package/dist/svelte/molecule/toast.module.scss +12 -0
- package/dist/svelte/stores/toast.d.ts +45 -0
- package/dist/svelte/stores/toast.js +55 -0
- package/dist/svelte/stores/validation.d.ts +15 -0
- package/dist/svelte/stores/validation.js +38 -0
- package/global.scss +1 -0
- package/package.json +32 -5
- package/vite-fonts.scss +1 -1
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import { createValidation } from '../stores/validation';
|
|
5
|
+
import styles from '../../react/atom/editor.module.scss';
|
|
6
|
+
|
|
7
|
+
export type ToolbarItem =
|
|
8
|
+
| 'undo-redo'
|
|
9
|
+
| 'paragraph'
|
|
10
|
+
| 'text-style'
|
|
11
|
+
| 'color'
|
|
12
|
+
| 'align'
|
|
13
|
+
| 'list'
|
|
14
|
+
| 'table'
|
|
15
|
+
| 'link'
|
|
16
|
+
| 'image'
|
|
17
|
+
| 'youtube'
|
|
18
|
+
| 'hr'
|
|
19
|
+
| 'format'
|
|
20
|
+
| 'code';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/** HTML content */
|
|
24
|
+
value?: string;
|
|
25
|
+
/** Editor width */
|
|
26
|
+
width?: string;
|
|
27
|
+
/** Editor height */
|
|
28
|
+
height?: string | 'contents';
|
|
29
|
+
/** Minimum height */
|
|
30
|
+
minHeight?: string;
|
|
31
|
+
/** Maximum height */
|
|
32
|
+
maxHeight?: string;
|
|
33
|
+
/** Allow resizing */
|
|
34
|
+
resizable?: boolean;
|
|
35
|
+
/** Change handler */
|
|
36
|
+
onchange?: (content: string) => void;
|
|
37
|
+
/** Zod validator */
|
|
38
|
+
validator?: z.ZodType<unknown>;
|
|
39
|
+
/** Placeholder text */
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
/** Toolbar items to show */
|
|
42
|
+
toolbar?: ToolbarItem[];
|
|
43
|
+
/** Additional class name */
|
|
44
|
+
class?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
value = $bindable(''),
|
|
49
|
+
width = '100%',
|
|
50
|
+
height = '400px',
|
|
51
|
+
minHeight,
|
|
52
|
+
maxHeight,
|
|
53
|
+
resizable = false,
|
|
54
|
+
onchange,
|
|
55
|
+
validator,
|
|
56
|
+
placeholder = '내용을 입력하세요...',
|
|
57
|
+
toolbar,
|
|
58
|
+
class: className = '',
|
|
59
|
+
...rest
|
|
60
|
+
}: Props & Record<string, unknown> = $props();
|
|
61
|
+
|
|
62
|
+
const { message, statusClass, validate } = createValidation(validator);
|
|
63
|
+
|
|
64
|
+
// State
|
|
65
|
+
let currentParagraphStyle = $state('p');
|
|
66
|
+
let isParagraphDropdownOpen = $state(false);
|
|
67
|
+
let isTextColorOpen = $state(false);
|
|
68
|
+
let isBgColorOpen = $state(false);
|
|
69
|
+
let isAlignDropdownOpen = $state(false);
|
|
70
|
+
let currentAlign = $state('left');
|
|
71
|
+
let isLinkDropdownOpen = $state(false);
|
|
72
|
+
let linkUrl = $state('');
|
|
73
|
+
let linkTarget = $state('_blank');
|
|
74
|
+
let isEditLinkPopupOpen = $state(false);
|
|
75
|
+
let selectedLinkElement = $state<HTMLAnchorElement | null>(null);
|
|
76
|
+
let editLinkUrl = $state('');
|
|
77
|
+
let editLinkTarget = $state('_self');
|
|
78
|
+
let savedSelection = $state<Range | null>(null);
|
|
79
|
+
let isImageDropdownOpen = $state(false);
|
|
80
|
+
let imageTabMode = $state<'file' | 'url'>('file');
|
|
81
|
+
let imageUrl = $state('');
|
|
82
|
+
let imageWidth = $state('original');
|
|
83
|
+
let imageAlign = $state('left');
|
|
84
|
+
let imageAlt = $state('');
|
|
85
|
+
let imageFile = $state<File | null>(null);
|
|
86
|
+
let imagePreview = $state('');
|
|
87
|
+
let savedImageSelection = $state<Range | null>(null);
|
|
88
|
+
let selectedImage = $state<HTMLImageElement | null>(null);
|
|
89
|
+
let isImageEditPopupOpen = $state(false);
|
|
90
|
+
let editImageWidth = $state('');
|
|
91
|
+
let editImageAlign = $state('left');
|
|
92
|
+
let editImageAlt = $state('');
|
|
93
|
+
let isResizing = $state(false);
|
|
94
|
+
let resizeStartData = $state<{ startX: number; startY: number; startWidth: number; startHeight: number; handle: string } | null>(null);
|
|
95
|
+
|
|
96
|
+
// YouTube state
|
|
97
|
+
let isYoutubeDropdownOpen = $state(false);
|
|
98
|
+
let youtubeUrl = $state('');
|
|
99
|
+
let savedYoutubeSelection = $state<Range | null>(null);
|
|
100
|
+
let youtubeWidth = $state('100%');
|
|
101
|
+
let youtubeAlign = $state('center');
|
|
102
|
+
let selectedYoutube = $state<HTMLElement | null>(null);
|
|
103
|
+
let isYoutubeEditPopupOpen = $state(false);
|
|
104
|
+
let editYoutubeWidth = $state('100%');
|
|
105
|
+
let editYoutubeAlign = $state('center');
|
|
106
|
+
let isCodeView = $state(false);
|
|
107
|
+
let codeContent = $state('');
|
|
108
|
+
let originalHtml = $state('');
|
|
109
|
+
let savedEditorHeight = $state<number | null>(null);
|
|
110
|
+
|
|
111
|
+
// Undo/Redo history
|
|
112
|
+
let history = $state<string[]>([value]);
|
|
113
|
+
let historyIndex = $state(0);
|
|
114
|
+
let historyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
115
|
+
let isUndoRedo = false;
|
|
116
|
+
let isComposing = false;
|
|
117
|
+
let justComposed = false;
|
|
118
|
+
|
|
119
|
+
// Table state
|
|
120
|
+
let isTableDropdownOpen = $state(false);
|
|
121
|
+
let tableRows = $state(0);
|
|
122
|
+
let tableCols = $state(0);
|
|
123
|
+
let savedTableSelection = $state<Range | null>(null);
|
|
124
|
+
let isTableContextMenuOpen = $state(false);
|
|
125
|
+
let tableContextMenuPosition = $state({ x: 0, y: 0 });
|
|
126
|
+
let selectedTableCell = $state<HTMLTableCellElement | null>(null);
|
|
127
|
+
let isTableCellColorOpen = $state(false);
|
|
128
|
+
let selectedTableCells = $state<HTMLTableCellElement[]>([]);
|
|
129
|
+
let selectionStartCell: HTMLTableCellElement | null = null;
|
|
130
|
+
let isSelectingCells = false;
|
|
131
|
+
let justFinishedDragging = false;
|
|
132
|
+
let isMouseDown = false;
|
|
133
|
+
|
|
134
|
+
// Refs
|
|
135
|
+
let editorRef = $state<HTMLDivElement | null>(null);
|
|
136
|
+
let codeEditorRef = $state<HTMLTextAreaElement | null>(null);
|
|
137
|
+
let containerRef = $state<HTMLDivElement | null>(null);
|
|
138
|
+
let fileInputRef = $state<HTMLInputElement | null>(null);
|
|
139
|
+
let imageFileInputRef = $state<HTMLInputElement | null>(null);
|
|
140
|
+
let tableContextMenuRef = $state<HTMLDivElement | null>(null);
|
|
141
|
+
|
|
142
|
+
let editorID = $state(`podo-editor-${Math.random().toString(36).slice(2, 9)}`);
|
|
143
|
+
|
|
144
|
+
// Toolbar config
|
|
145
|
+
const defaultToolbar: ToolbarItem[] = [
|
|
146
|
+
'undo-redo', 'paragraph', 'text-style', 'color', 'align', 'list',
|
|
147
|
+
'table', 'link', 'image', 'youtube', 'hr', 'format', 'code',
|
|
148
|
+
];
|
|
149
|
+
const activeToolbar = $derived(toolbar || defaultToolbar);
|
|
150
|
+
|
|
151
|
+
const isToolbarItemEnabled = (item: ToolbarItem) => activeToolbar.includes(item);
|
|
152
|
+
|
|
153
|
+
// Color palette
|
|
154
|
+
const colorPalette = [
|
|
155
|
+
['#ff0000', '#ff8000', '#ffff00', '#80ff00', '#00ffff', '#0080ff', '#0000ff', '#8000ff', '#ff00ff', '#ffffff', '#000000'],
|
|
156
|
+
['#ffcccc', '#ffe0cc', '#ffffcc', '#e0ffcc', '#ccffff', '#cce0ff', '#ccccff', '#e0ccff', '#ffccff', '#f5f5f5', '#cccccc'],
|
|
157
|
+
['#ff9999', '#ffcc99', '#ffff99', '#ccff99', '#99ffff', '#99ccff', '#9999ff', '#cc99ff', '#ff99ff', '#e6e6e6', '#999999'],
|
|
158
|
+
['#ff6666', '#ffb366', '#ffff66', '#b3ff66', '#66ffff', '#66b3ff', '#6666ff', '#b366ff', '#ff66ff', '#d9d9d9', '#666666'],
|
|
159
|
+
['#cc0000', '#cc6600', '#cccc00', '#66cc00', '#00cccc', '#0066cc', '#0000cc', '#6600cc', '#cc00cc', '#b3b3b3', '#333333'],
|
|
160
|
+
['#800000', '#804000', '#808000', '#408000', '#008080', '#004080', '#000080', '#400080', '#800080', '#808080', '#1a1a1a'],
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
// Align options
|
|
164
|
+
const alignOptions = [
|
|
165
|
+
{ value: 'left', label: '왼쪽 정렬', icon: 'alignLeft' },
|
|
166
|
+
{ value: 'center', label: '가운데 정렬', icon: 'alignCenter' },
|
|
167
|
+
{ value: 'right', label: '오른쪽 정렬', icon: 'alignRight' },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
// Paragraph options
|
|
171
|
+
const paragraphOptions = [
|
|
172
|
+
{ value: 'h1', label: '제목 1' },
|
|
173
|
+
{ value: 'h2', label: '제목 2' },
|
|
174
|
+
{ value: 'h3', label: '제목 3' },
|
|
175
|
+
{ value: 'p', label: '본문', className: styles.pDefault },
|
|
176
|
+
{ value: 'p1', label: 'P1', className: styles.p1Preview },
|
|
177
|
+
{ value: 'p2', label: 'P2', className: styles.p2Preview },
|
|
178
|
+
{ value: 'p3', label: 'P3', className: styles.p3Preview },
|
|
179
|
+
{ value: 'p3_semibold', label: 'P3 Semibold', className: styles.p3_semiboldPreview },
|
|
180
|
+
{ value: 'p4', label: 'P4', className: styles.p4Preview },
|
|
181
|
+
{ value: 'p4_semibold', label: 'P4 Semibold', className: styles.p4_semiboldPreview },
|
|
182
|
+
{ value: 'p5', label: 'P5', className: styles.p5Preview },
|
|
183
|
+
{ value: 'p5_semibold', label: 'P5 Semibold', className: styles.p5_semiboldPreview },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const getCurrentStyleLabel = () => {
|
|
187
|
+
const option = paragraphOptions.find(opt => opt.value === currentParagraphStyle);
|
|
188
|
+
return option ? option.label : '문단 형식';
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const getCurrentAlignLabel = () => {
|
|
192
|
+
const option = alignOptions.find(opt => opt.value === currentAlign);
|
|
193
|
+
return option ? option.label : '왼쪽 정렬';
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const getCurrentAlignIcon = () => {
|
|
197
|
+
const option = alignOptions.find(opt => opt.value === currentAlign);
|
|
198
|
+
return option ? styles[option.icon] : styles.alignLeft;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const validateHandler = (content: string) => {
|
|
202
|
+
if (validator) {
|
|
203
|
+
validate(content);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const detectCurrentAlign = () => {
|
|
208
|
+
if (document.queryCommandState('justifyLeft')) {
|
|
209
|
+
currentAlign = 'left';
|
|
210
|
+
} else if (document.queryCommandState('justifyCenter')) {
|
|
211
|
+
currentAlign = 'center';
|
|
212
|
+
} else if (document.queryCommandState('justifyRight')) {
|
|
213
|
+
currentAlign = 'right';
|
|
214
|
+
} else {
|
|
215
|
+
currentAlign = 'left';
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const detectCurrentParagraphStyle = () => {
|
|
220
|
+
const selection = window.getSelection();
|
|
221
|
+
if (!selection || selection.rangeCount === 0) {
|
|
222
|
+
currentParagraphStyle = 'p';
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let container: Node | null = selection.getRangeAt(0).commonAncestorContainer;
|
|
227
|
+
if (container.nodeType === Node.TEXT_NODE) {
|
|
228
|
+
container = container.parentNode;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
while (container && container !== editorRef) {
|
|
232
|
+
const element = container as Element;
|
|
233
|
+
if (element.tagName) {
|
|
234
|
+
const tagName = element.tagName.toLowerCase();
|
|
235
|
+
|
|
236
|
+
if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') {
|
|
237
|
+
currentParagraphStyle = tagName;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (tagName === 'p') {
|
|
242
|
+
if (element.className) {
|
|
243
|
+
const classNames = Object.keys(styles);
|
|
244
|
+
for (const cls of classNames) {
|
|
245
|
+
if (cls.match(/^p[1-5](_semibold)?$/) && element.classList.contains(styles[cls])) {
|
|
246
|
+
currentParagraphStyle = cls;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
currentParagraphStyle = 'p';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (tagName === 'div' || tagName === 'blockquote' || tagName === 'pre') {
|
|
256
|
+
currentParagraphStyle = 'p';
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
container = (container as Element).parentNode;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
currentParagraphStyle = 'p';
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Add to history with debounce
|
|
267
|
+
const addToHistory = (content: string) => {
|
|
268
|
+
if (historyTimer) {
|
|
269
|
+
clearTimeout(historyTimer);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
historyTimer = setTimeout(() => {
|
|
273
|
+
const newHistory = history.slice(0, historyIndex + 1);
|
|
274
|
+
|
|
275
|
+
if (newHistory[newHistory.length - 1] === content) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const updated = [...newHistory, content];
|
|
280
|
+
if (updated.length > 200) {
|
|
281
|
+
updated.shift();
|
|
282
|
+
history = updated;
|
|
283
|
+
} else {
|
|
284
|
+
history = updated;
|
|
285
|
+
historyIndex = updated.length - 1;
|
|
286
|
+
}
|
|
287
|
+
}, 500);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Undo
|
|
291
|
+
const performUndo = () => {
|
|
292
|
+
if (historyTimer) {
|
|
293
|
+
clearTimeout(historyTimer);
|
|
294
|
+
historyTimer = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (historyIndex > 0) {
|
|
298
|
+
const newIndex = historyIndex - 1;
|
|
299
|
+
const content = history[newIndex];
|
|
300
|
+
historyIndex = newIndex;
|
|
301
|
+
|
|
302
|
+
if (editorRef) {
|
|
303
|
+
isUndoRedo = true;
|
|
304
|
+
editorRef.innerHTML = content;
|
|
305
|
+
value = content;
|
|
306
|
+
onchange?.(content);
|
|
307
|
+
detectCurrentParagraphStyle();
|
|
308
|
+
detectCurrentAlign();
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
isUndoRedo = false;
|
|
311
|
+
}, 0);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Redo
|
|
317
|
+
const performRedo = () => {
|
|
318
|
+
if (historyTimer) {
|
|
319
|
+
clearTimeout(historyTimer);
|
|
320
|
+
historyTimer = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (historyIndex < history.length - 1) {
|
|
324
|
+
const newIndex = historyIndex + 1;
|
|
325
|
+
const content = history[newIndex];
|
|
326
|
+
historyIndex = newIndex;
|
|
327
|
+
|
|
328
|
+
if (editorRef) {
|
|
329
|
+
isUndoRedo = true;
|
|
330
|
+
editorRef.innerHTML = content;
|
|
331
|
+
value = content;
|
|
332
|
+
onchange?.(content);
|
|
333
|
+
detectCurrentParagraphStyle();
|
|
334
|
+
detectCurrentAlign();
|
|
335
|
+
setTimeout(() => {
|
|
336
|
+
isUndoRedo = false;
|
|
337
|
+
}, 0);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const handleInput = () => {
|
|
343
|
+
if (isComposing) return;
|
|
344
|
+
|
|
345
|
+
if (justComposed) {
|
|
346
|
+
justComposed = false;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (editorRef) {
|
|
351
|
+
const content = editorRef.innerHTML;
|
|
352
|
+
value = content;
|
|
353
|
+
onchange?.(content);
|
|
354
|
+
validateHandler(content);
|
|
355
|
+
detectCurrentParagraphStyle();
|
|
356
|
+
detectCurrentAlign();
|
|
357
|
+
addToHistory(content);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const handleCompositionStart = () => {
|
|
362
|
+
isComposing = true;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const handleCompositionEnd = () => {
|
|
366
|
+
isComposing = false;
|
|
367
|
+
justComposed = true;
|
|
368
|
+
|
|
369
|
+
if (editorRef) {
|
|
370
|
+
const content = editorRef.innerHTML;
|
|
371
|
+
value = content;
|
|
372
|
+
onchange?.(content);
|
|
373
|
+
validateHandler(content);
|
|
374
|
+
detectCurrentParagraphStyle();
|
|
375
|
+
detectCurrentAlign();
|
|
376
|
+
addToHistory(content);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Insert image into editor
|
|
381
|
+
const insertImageAtCursor = (src: string, alt = '') => {
|
|
382
|
+
const selection = window.getSelection();
|
|
383
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
384
|
+
|
|
385
|
+
const range = selection.getRangeAt(0);
|
|
386
|
+
const img = document.createElement('img');
|
|
387
|
+
img.src = src;
|
|
388
|
+
img.alt = alt;
|
|
389
|
+
img.style.maxWidth = '100%';
|
|
390
|
+
|
|
391
|
+
range.deleteContents();
|
|
392
|
+
range.insertNode(img);
|
|
393
|
+
|
|
394
|
+
// Move cursor after image
|
|
395
|
+
const newRange = document.createRange();
|
|
396
|
+
newRange.setStartAfter(img);
|
|
397
|
+
newRange.collapse(true);
|
|
398
|
+
selection.removeAllRanges();
|
|
399
|
+
selection.addRange(newRange);
|
|
400
|
+
|
|
401
|
+
handleInput();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Drag and drop handlers
|
|
405
|
+
const handleDragOver = (e: DragEvent) => {
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
e.stopPropagation();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const handleDrop = (e: DragEvent) => {
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
e.stopPropagation();
|
|
413
|
+
|
|
414
|
+
const files = e.dataTransfer?.files;
|
|
415
|
+
if (!files || files.length === 0) return;
|
|
416
|
+
|
|
417
|
+
// Process image files only
|
|
418
|
+
for (let i = 0; i < files.length; i++) {
|
|
419
|
+
const file = files[i];
|
|
420
|
+
if (file.type.startsWith('image/')) {
|
|
421
|
+
const reader = new FileReader();
|
|
422
|
+
reader.onload = (event) => {
|
|
423
|
+
const dataUrl = event.target?.result as string;
|
|
424
|
+
if (dataUrl) {
|
|
425
|
+
// Focus editor at drop position
|
|
426
|
+
editorRef?.focus();
|
|
427
|
+
insertImageAtCursor(dataUrl, file.name || 'dropped-image');
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
reader.readAsDataURL(file);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Paste handler
|
|
436
|
+
const handlePaste = (e: ClipboardEvent) => {
|
|
437
|
+
const clipboardData = e.clipboardData;
|
|
438
|
+
if (!clipboardData) return;
|
|
439
|
+
|
|
440
|
+
// Check for image files in clipboard
|
|
441
|
+
const items = clipboardData.items;
|
|
442
|
+
if (items) {
|
|
443
|
+
for (let i = 0; i < items.length; i++) {
|
|
444
|
+
const item = items[i];
|
|
445
|
+
if (item.type.startsWith('image/')) {
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
const file = item.getAsFile();
|
|
448
|
+
if (file) {
|
|
449
|
+
const reader = new FileReader();
|
|
450
|
+
reader.onload = (event) => {
|
|
451
|
+
const dataUrl = event.target?.result as string;
|
|
452
|
+
if (dataUrl) {
|
|
453
|
+
insertImageAtCursor(dataUrl, file.name || 'pasted-image');
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
reader.readAsDataURL(file);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Check for files (drag & drop or file paste)
|
|
464
|
+
const files = clipboardData.files;
|
|
465
|
+
if (files && files.length > 0) {
|
|
466
|
+
for (let i = 0; i < files.length; i++) {
|
|
467
|
+
const file = files[i];
|
|
468
|
+
if (file.type.startsWith('image/')) {
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
const reader = new FileReader();
|
|
471
|
+
reader.onload = (event) => {
|
|
472
|
+
const dataUrl = event.target?.result as string;
|
|
473
|
+
if (dataUrl) {
|
|
474
|
+
insertImageAtCursor(dataUrl, file.name || 'pasted-image');
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
reader.readAsDataURL(file);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Handle HTML/text paste
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
|
|
486
|
+
const html = clipboardData.getData('text/html');
|
|
487
|
+
const text = clipboardData.getData('text/plain');
|
|
488
|
+
|
|
489
|
+
if (html) {
|
|
490
|
+
const tempDiv = document.createElement('div');
|
|
491
|
+
tempDiv.innerHTML = html;
|
|
492
|
+
|
|
493
|
+
const allowedTags = ['P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'DEL',
|
|
494
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE',
|
|
495
|
+
'UL', 'OL', 'LI', 'A', 'IMG', 'SPAN', 'DIV', 'HR',
|
|
496
|
+
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD'];
|
|
497
|
+
|
|
498
|
+
const cleanElement = (element: Element): Node | null => {
|
|
499
|
+
const tagName = element.tagName;
|
|
500
|
+
|
|
501
|
+
if (!allowedTags.includes(tagName)) {
|
|
502
|
+
const fragment = document.createDocumentFragment();
|
|
503
|
+
Array.from(element.childNodes).forEach(child => {
|
|
504
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
505
|
+
const cleaned = cleanElement(child as Element);
|
|
506
|
+
if (cleaned) fragment.appendChild(cleaned);
|
|
507
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
508
|
+
fragment.appendChild(child.cloneNode(true));
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return fragment.childNodes.length > 0 ? fragment : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const newElement = element.cloneNode(false) as HTMLElement;
|
|
515
|
+
const attrs = Array.from(element.attributes);
|
|
516
|
+
attrs.forEach(attr => newElement.removeAttribute(attr.name));
|
|
517
|
+
|
|
518
|
+
if (tagName === 'A' && element.getAttribute('href')) {
|
|
519
|
+
newElement.setAttribute('href', element.getAttribute('href')!);
|
|
520
|
+
if (element.getAttribute('target')) {
|
|
521
|
+
newElement.setAttribute('target', element.getAttribute('target')!);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (tagName === 'IMG') {
|
|
526
|
+
if (element.getAttribute('src')) {
|
|
527
|
+
newElement.setAttribute('src', element.getAttribute('src')!);
|
|
528
|
+
}
|
|
529
|
+
if (element.getAttribute('alt')) {
|
|
530
|
+
newElement.setAttribute('alt', element.getAttribute('alt')!);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (tagName === 'SPAN' || tagName === 'P' || tagName === 'DIV') {
|
|
535
|
+
const style = (element as HTMLElement).style;
|
|
536
|
+
const allowedStyles: string[] = [];
|
|
537
|
+
|
|
538
|
+
if (style.color) allowedStyles.push(`color: ${style.color}`);
|
|
539
|
+
if (style.backgroundColor) allowedStyles.push(`background-color: ${style.backgroundColor}`);
|
|
540
|
+
if (style.textAlign) allowedStyles.push(`text-align: ${style.textAlign}`);
|
|
541
|
+
|
|
542
|
+
if (allowedStyles.length > 0) {
|
|
543
|
+
newElement.setAttribute('style', allowedStyles.join('; '));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
Array.from(element.childNodes).forEach(child => {
|
|
548
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
549
|
+
const cleaned = cleanElement(child as Element);
|
|
550
|
+
if (cleaned) newElement.appendChild(cleaned);
|
|
551
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
552
|
+
newElement.appendChild(child.cloneNode(true));
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return newElement;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const cleanedContent = document.createDocumentFragment();
|
|
560
|
+
Array.from(tempDiv.childNodes).forEach(child => {
|
|
561
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
562
|
+
const cleaned = cleanElement(child as Element);
|
|
563
|
+
if (cleaned) cleanedContent.appendChild(cleaned);
|
|
564
|
+
} else if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
|
|
565
|
+
cleanedContent.appendChild(child.cloneNode(true));
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const selection = window.getSelection();
|
|
570
|
+
if (selection && selection.rangeCount > 0) {
|
|
571
|
+
const range = selection.getRangeAt(0);
|
|
572
|
+
range.deleteContents();
|
|
573
|
+
range.insertNode(cleanedContent);
|
|
574
|
+
range.collapse(false);
|
|
575
|
+
selection.removeAllRanges();
|
|
576
|
+
selection.addRange(range);
|
|
577
|
+
}
|
|
578
|
+
} else if (text) {
|
|
579
|
+
document.execCommand('insertText', false, text);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
handleInput();
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
// Text formatting commands
|
|
586
|
+
const execCommand = (command: string, value?: string) => {
|
|
587
|
+
document.execCommand(command, false, value);
|
|
588
|
+
editorRef?.focus();
|
|
589
|
+
handleInput();
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const formatBold = () => execCommand('bold');
|
|
593
|
+
const formatItalic = () => execCommand('italic');
|
|
594
|
+
const formatUnderline = () => execCommand('underline');
|
|
595
|
+
const formatStrikethrough = () => execCommand('strikethrough');
|
|
596
|
+
const removeFormat = () => execCommand('removeFormat');
|
|
597
|
+
|
|
598
|
+
const setTextColor = (color: string) => {
|
|
599
|
+
execCommand('foreColor', color);
|
|
600
|
+
isTextColorOpen = false;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const setBackgroundColor = (color: string) => {
|
|
604
|
+
execCommand('hiliteColor', color);
|
|
605
|
+
isBgColorOpen = false;
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const setAlignment = (align: string) => {
|
|
609
|
+
if (align === 'left') execCommand('justifyLeft');
|
|
610
|
+
else if (align === 'center') execCommand('justifyCenter');
|
|
611
|
+
else if (align === 'right') execCommand('justifyRight');
|
|
612
|
+
currentAlign = align;
|
|
613
|
+
isAlignDropdownOpen = false;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const insertList = (ordered: boolean) => {
|
|
617
|
+
execCommand(ordered ? 'insertOrderedList' : 'insertUnorderedList');
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Paragraph format
|
|
621
|
+
const setParagraphFormat = (format: string) => {
|
|
622
|
+
editorRef?.focus();
|
|
623
|
+
const selection = window.getSelection();
|
|
624
|
+
|
|
625
|
+
if (!selection || selection.rangeCount === 0) {
|
|
626
|
+
isParagraphDropdownOpen = false;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (format === 'h1' || format === 'h2' || format === 'h3') {
|
|
631
|
+
document.execCommand('formatBlock', false, format.toUpperCase());
|
|
632
|
+
} else if (format === 'p') {
|
|
633
|
+
document.execCommand('formatBlock', false, 'P');
|
|
634
|
+
} else if (format.startsWith('p') && (format.match(/p[1-5](_semibold)?/))) {
|
|
635
|
+
document.execCommand('formatBlock', false, 'P');
|
|
636
|
+
|
|
637
|
+
setTimeout(() => {
|
|
638
|
+
const sel = window.getSelection();
|
|
639
|
+
if (sel && sel.rangeCount > 0) {
|
|
640
|
+
let node: Node | null = sel.getRangeAt(0).startContainer;
|
|
641
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
642
|
+
node = node.parentNode;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
while (node && node !== editorRef) {
|
|
646
|
+
if ((node as Element).tagName === 'P') {
|
|
647
|
+
const p = node as HTMLParagraphElement;
|
|
648
|
+
Object.keys(styles).forEach(cls => {
|
|
649
|
+
if (cls.match(/^p[1-5](_semibold)?$/)) {
|
|
650
|
+
p.classList.remove(styles[cls]);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
p.classList.add(styles[format]);
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
node = node.parentNode;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}, 0);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
currentParagraphStyle = format;
|
|
663
|
+
isParagraphDropdownOpen = false;
|
|
664
|
+
handleInput();
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// Link functions
|
|
668
|
+
const insertLink = () => {
|
|
669
|
+
if (!linkUrl.trim()) return;
|
|
670
|
+
|
|
671
|
+
editorRef?.focus();
|
|
672
|
+
|
|
673
|
+
if (savedSelection) {
|
|
674
|
+
const selection = window.getSelection();
|
|
675
|
+
if (selection) {
|
|
676
|
+
selection.removeAllRanges();
|
|
677
|
+
selection.addRange(savedSelection);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const selection = window.getSelection();
|
|
682
|
+
if (selection && selection.rangeCount > 0) {
|
|
683
|
+
const range = selection.getRangeAt(0);
|
|
684
|
+
const selectedText = range.toString();
|
|
685
|
+
|
|
686
|
+
if (selectedText) {
|
|
687
|
+
const a = document.createElement('a');
|
|
688
|
+
a.href = linkUrl;
|
|
689
|
+
a.target = linkTarget;
|
|
690
|
+
a.textContent = selectedText;
|
|
691
|
+
range.deleteContents();
|
|
692
|
+
range.insertNode(a);
|
|
693
|
+
} else {
|
|
694
|
+
const a = document.createElement('a');
|
|
695
|
+
a.href = linkUrl;
|
|
696
|
+
a.target = linkTarget;
|
|
697
|
+
a.textContent = linkUrl;
|
|
698
|
+
range.insertNode(a);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
linkUrl = '';
|
|
703
|
+
linkTarget = '_blank';
|
|
704
|
+
savedSelection = null;
|
|
705
|
+
isLinkDropdownOpen = false;
|
|
706
|
+
handleInput();
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// HR insert
|
|
710
|
+
const insertHR = () => {
|
|
711
|
+
if (!editorRef) return;
|
|
712
|
+
|
|
713
|
+
editorRef.focus();
|
|
714
|
+
const selection = window.getSelection();
|
|
715
|
+
|
|
716
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
717
|
+
|
|
718
|
+
const range = selection.getRangeAt(0);
|
|
719
|
+
|
|
720
|
+
const hr = document.createElement('hr');
|
|
721
|
+
hr.style.border = 'none';
|
|
722
|
+
hr.style.borderTop = '1px solid #ddd';
|
|
723
|
+
hr.style.margin = '10px 0';
|
|
724
|
+
|
|
725
|
+
const newP = document.createElement('p');
|
|
726
|
+
newP.innerHTML = '<br>';
|
|
727
|
+
|
|
728
|
+
range.deleteContents();
|
|
729
|
+
range.insertNode(hr);
|
|
730
|
+
hr.after(newP);
|
|
731
|
+
|
|
732
|
+
const newRange = document.createRange();
|
|
733
|
+
newRange.selectNodeContents(newP);
|
|
734
|
+
newRange.collapse(true);
|
|
735
|
+
selection.removeAllRanges();
|
|
736
|
+
selection.addRange(newRange);
|
|
737
|
+
|
|
738
|
+
editorRef.focus();
|
|
739
|
+
handleInput();
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// Table insert
|
|
743
|
+
const insertTable = (rows: number, cols: number) => {
|
|
744
|
+
if (rows === 0 || cols === 0) return;
|
|
745
|
+
|
|
746
|
+
const table = document.createElement('table');
|
|
747
|
+
table.style.borderCollapse = 'collapse';
|
|
748
|
+
table.style.width = '100%';
|
|
749
|
+
table.style.margin = '10px 0';
|
|
750
|
+
table.setAttribute('border', '1');
|
|
751
|
+
table.style.border = '1px solid #ddd';
|
|
752
|
+
|
|
753
|
+
const tbody = document.createElement('tbody');
|
|
754
|
+
|
|
755
|
+
for (let i = 0; i < rows; i++) {
|
|
756
|
+
const tr = document.createElement('tr');
|
|
757
|
+
|
|
758
|
+
for (let j = 0; j < cols; j++) {
|
|
759
|
+
const td = document.createElement('td');
|
|
760
|
+
td.style.border = '1px solid #ddd';
|
|
761
|
+
td.style.padding = '8px';
|
|
762
|
+
td.style.minWidth = '50px';
|
|
763
|
+
td.innerHTML = '<br>';
|
|
764
|
+
tr.appendChild(td);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
tbody.appendChild(tr);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
table.appendChild(tbody);
|
|
771
|
+
|
|
772
|
+
if (editorRef) {
|
|
773
|
+
editorRef.focus();
|
|
774
|
+
|
|
775
|
+
const selection = window.getSelection();
|
|
776
|
+
|
|
777
|
+
if (savedTableSelection && selection) {
|
|
778
|
+
try {
|
|
779
|
+
selection.removeAllRanges();
|
|
780
|
+
selection.addRange(savedTableSelection);
|
|
781
|
+
} catch {
|
|
782
|
+
// ignore
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
|
|
787
|
+
if (!editorRef.innerHTML || editorRef.innerHTML === '<br>') {
|
|
788
|
+
const p = document.createElement('p');
|
|
789
|
+
p.innerHTML = '<br>';
|
|
790
|
+
editorRef.appendChild(p);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const range = document.createRange();
|
|
794
|
+
range.selectNodeContents(editorRef);
|
|
795
|
+
range.collapse(false);
|
|
796
|
+
selection?.removeAllRanges();
|
|
797
|
+
selection?.addRange(range);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (selection && selection.rangeCount > 0) {
|
|
801
|
+
const range = selection.getRangeAt(0);
|
|
802
|
+
range.deleteContents();
|
|
803
|
+
range.insertNode(table);
|
|
804
|
+
|
|
805
|
+
const newP = document.createElement('p');
|
|
806
|
+
newP.innerHTML = '<br>';
|
|
807
|
+
table.after(newP);
|
|
808
|
+
|
|
809
|
+
const firstCell = table.querySelector('td');
|
|
810
|
+
if (firstCell) {
|
|
811
|
+
const newRange = document.createRange();
|
|
812
|
+
newRange.selectNodeContents(firstCell);
|
|
813
|
+
newRange.collapse(true);
|
|
814
|
+
selection.removeAllRanges();
|
|
815
|
+
selection.addRange(newRange);
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
editorRef.appendChild(table);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
isTableDropdownOpen = false;
|
|
823
|
+
tableRows = 0;
|
|
824
|
+
tableCols = 0;
|
|
825
|
+
savedTableSelection = null;
|
|
826
|
+
|
|
827
|
+
editorRef?.focus();
|
|
828
|
+
handleInput();
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// Code view toggle
|
|
832
|
+
const toggleCodeView = () => {
|
|
833
|
+
if (!editorRef) return;
|
|
834
|
+
|
|
835
|
+
if (!isCodeView) {
|
|
836
|
+
// Switch to code view
|
|
837
|
+
savedEditorHeight = editorRef.offsetHeight;
|
|
838
|
+
originalHtml = editorRef.innerHTML;
|
|
839
|
+
|
|
840
|
+
// Format HTML
|
|
841
|
+
const formatted = originalHtml
|
|
842
|
+
.replace(/></g, '>\n<')
|
|
843
|
+
.replace(/(<\/?(?:p|div|h[1-6]|ul|ol|li|table|tr|td|th|tbody|thead|blockquote|pre|hr)[^>]*>)/gi, '\n$1\n')
|
|
844
|
+
.split('\n')
|
|
845
|
+
.filter(line => line.trim())
|
|
846
|
+
.join('\n');
|
|
847
|
+
|
|
848
|
+
codeContent = formatted;
|
|
849
|
+
isCodeView = true;
|
|
850
|
+
} else {
|
|
851
|
+
// Switch back to WYSIWYG
|
|
852
|
+
if (editorRef) {
|
|
853
|
+
editorRef.innerHTML = codeContent.replace(/\n/g, '');
|
|
854
|
+
handleInput();
|
|
855
|
+
}
|
|
856
|
+
isCodeView = false;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// Keydown handler
|
|
861
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
862
|
+
// Undo: Ctrl/Cmd + Z
|
|
863
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
performUndo();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
|
|
870
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
|
871
|
+
e.preventDefault();
|
|
872
|
+
performRedo();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Bold: Ctrl/Cmd + B
|
|
877
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
|
878
|
+
e.preventDefault();
|
|
879
|
+
formatBold();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Italic: Ctrl/Cmd + I
|
|
884
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
formatItalic();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Underline: Ctrl/Cmd + U
|
|
891
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'u') {
|
|
892
|
+
e.preventDefault();
|
|
893
|
+
formatUnderline();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Click outside to close dropdowns
|
|
899
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
900
|
+
const target = e.target as HTMLElement;
|
|
901
|
+
|
|
902
|
+
if (!target.closest(`.${styles.paragraphDropdown}`) && !target.closest(`.${styles.paragraphButton}`)) {
|
|
903
|
+
isParagraphDropdownOpen = false;
|
|
904
|
+
}
|
|
905
|
+
if (!target.closest(`.${styles.colorDropdown}`) && !target.closest(`.${styles.colorButton}`)) {
|
|
906
|
+
isTextColorOpen = false;
|
|
907
|
+
isBgColorOpen = false;
|
|
908
|
+
}
|
|
909
|
+
if (!target.closest(`.${styles.alignDropdown}`) && !target.closest(`.${styles.alignButton}`)) {
|
|
910
|
+
isAlignDropdownOpen = false;
|
|
911
|
+
}
|
|
912
|
+
if (!target.closest(`.${styles.linkDropdown}`) && !target.closest(`.${styles.linkButton}`)) {
|
|
913
|
+
isLinkDropdownOpen = false;
|
|
914
|
+
}
|
|
915
|
+
if (!target.closest(`.${styles.tableDropdown}`) && !target.closest(`.${styles.tableButton}`)) {
|
|
916
|
+
isTableDropdownOpen = false;
|
|
917
|
+
}
|
|
918
|
+
if (!target.closest(`.${styles.imageDropdown}`) && !target.closest(`.${styles.imageButton}`)) {
|
|
919
|
+
isImageDropdownOpen = false;
|
|
920
|
+
}
|
|
921
|
+
if (!target.closest(`.${styles.youtubeDropdown}`) && !target.closest(`.${styles.youtubeButton}`)) {
|
|
922
|
+
isYoutubeDropdownOpen = false;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// Initialize value
|
|
927
|
+
$effect(() => {
|
|
928
|
+
if (editorRef && value !== editorRef.innerHTML && !isUndoRedo) {
|
|
929
|
+
editorRef.innerHTML = value;
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
onMount(() => {
|
|
934
|
+
if (editorRef && value) {
|
|
935
|
+
editorRef.innerHTML = value;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (typeof document !== 'undefined') {
|
|
939
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
onDestroy(() => {
|
|
944
|
+
if (typeof document !== 'undefined') {
|
|
945
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
946
|
+
}
|
|
947
|
+
if (historyTimer) {
|
|
948
|
+
clearTimeout(historyTimer);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// Computed styles
|
|
953
|
+
const editorStyle = $derived.by(() => {
|
|
954
|
+
const styles: Record<string, string> = { width };
|
|
955
|
+
|
|
956
|
+
if (height === 'contents') {
|
|
957
|
+
styles.height = 'auto';
|
|
958
|
+
} else {
|
|
959
|
+
styles.height = height;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (minHeight) styles.minHeight = minHeight;
|
|
963
|
+
if (maxHeight) styles.maxHeight = maxHeight;
|
|
964
|
+
|
|
965
|
+
return Object.entries(styles).map(([k, v]) => `${k}: ${v}`).join('; ');
|
|
966
|
+
});
|
|
967
|
+
</script>
|
|
968
|
+
|
|
969
|
+
<div
|
|
970
|
+
bind:this={containerRef}
|
|
971
|
+
class="{styles.editor} {className}"
|
|
972
|
+
style="width: {width};"
|
|
973
|
+
{...rest}
|
|
974
|
+
>
|
|
975
|
+
<!-- Toolbar -->
|
|
976
|
+
<div class={styles.toolbar}>
|
|
977
|
+
<!-- Undo/Redo -->
|
|
978
|
+
{#if isToolbarItemEnabled('undo-redo')}
|
|
979
|
+
<div class={styles.toolbarGroup}>
|
|
980
|
+
<button
|
|
981
|
+
type="button"
|
|
982
|
+
class={styles.toolbarButton}
|
|
983
|
+
onclick={performUndo}
|
|
984
|
+
disabled={historyIndex <= 0}
|
|
985
|
+
title="실행 취소 (Ctrl+Z)"
|
|
986
|
+
>
|
|
987
|
+
<i class={styles.undo}></i>
|
|
988
|
+
</button>
|
|
989
|
+
<button
|
|
990
|
+
type="button"
|
|
991
|
+
class={styles.toolbarButton}
|
|
992
|
+
onclick={performRedo}
|
|
993
|
+
disabled={historyIndex >= history.length - 1}
|
|
994
|
+
title="다시 실행 (Ctrl+Y)"
|
|
995
|
+
>
|
|
996
|
+
<i class={styles.redo}></i>
|
|
997
|
+
</button>
|
|
998
|
+
</div>
|
|
999
|
+
{/if}
|
|
1000
|
+
|
|
1001
|
+
<!-- Paragraph format -->
|
|
1002
|
+
{#if isToolbarItemEnabled('paragraph')}
|
|
1003
|
+
<div class={styles.toolbarGroup}>
|
|
1004
|
+
<button
|
|
1005
|
+
type="button"
|
|
1006
|
+
class={styles.paragraphButton}
|
|
1007
|
+
onclick={() => isParagraphDropdownOpen = !isParagraphDropdownOpen}
|
|
1008
|
+
>
|
|
1009
|
+
<span>{getCurrentStyleLabel()}</span>
|
|
1010
|
+
<i class={styles.dropdownArrow}></i>
|
|
1011
|
+
</button>
|
|
1012
|
+
{#if isParagraphDropdownOpen}
|
|
1013
|
+
<div class={styles.paragraphDropdown}>
|
|
1014
|
+
{#each paragraphOptions as option}
|
|
1015
|
+
<button
|
|
1016
|
+
type="button"
|
|
1017
|
+
class="{styles.paragraphOption} {option.className || ''}"
|
|
1018
|
+
onclick={() => setParagraphFormat(option.value)}
|
|
1019
|
+
>
|
|
1020
|
+
{option.label}
|
|
1021
|
+
</button>
|
|
1022
|
+
{/each}
|
|
1023
|
+
</div>
|
|
1024
|
+
{/if}
|
|
1025
|
+
</div>
|
|
1026
|
+
{/if}
|
|
1027
|
+
|
|
1028
|
+
<!-- Text style -->
|
|
1029
|
+
{#if isToolbarItemEnabled('text-style')}
|
|
1030
|
+
<div class={styles.toolbarGroup}>
|
|
1031
|
+
<button type="button" class={styles.toolbarButton} onclick={formatBold} title="굵게 (Ctrl+B)">
|
|
1032
|
+
<i class={styles.bold}></i>
|
|
1033
|
+
</button>
|
|
1034
|
+
<button type="button" class={styles.toolbarButton} onclick={formatItalic} title="기울임 (Ctrl+I)">
|
|
1035
|
+
<i class={styles.italic}></i>
|
|
1036
|
+
</button>
|
|
1037
|
+
<button type="button" class={styles.toolbarButton} onclick={formatUnderline} title="밑줄 (Ctrl+U)">
|
|
1038
|
+
<i class={styles.underline}></i>
|
|
1039
|
+
</button>
|
|
1040
|
+
<button type="button" class={styles.toolbarButton} onclick={formatStrikethrough} title="취소선">
|
|
1041
|
+
<i class={styles.strikethrough}></i>
|
|
1042
|
+
</button>
|
|
1043
|
+
</div>
|
|
1044
|
+
{/if}
|
|
1045
|
+
|
|
1046
|
+
<!-- Color -->
|
|
1047
|
+
{#if isToolbarItemEnabled('color')}
|
|
1048
|
+
<div class={styles.toolbarGroup}>
|
|
1049
|
+
<div class={styles.colorButton}>
|
|
1050
|
+
<button
|
|
1051
|
+
type="button"
|
|
1052
|
+
class={styles.toolbarButton}
|
|
1053
|
+
onclick={() => { isTextColorOpen = !isTextColorOpen; isBgColorOpen = false; }}
|
|
1054
|
+
title="글꼴 색상"
|
|
1055
|
+
>
|
|
1056
|
+
<i class={styles.fontColor}></i>
|
|
1057
|
+
</button>
|
|
1058
|
+
{#if isTextColorOpen}
|
|
1059
|
+
<div class={styles.colorDropdown}>
|
|
1060
|
+
<div class={styles.colorPalette}>
|
|
1061
|
+
{#each colorPalette as row}
|
|
1062
|
+
<div class={styles.colorRow}>
|
|
1063
|
+
{#each row as color}
|
|
1064
|
+
<button
|
|
1065
|
+
type="button"
|
|
1066
|
+
class={styles.colorCell}
|
|
1067
|
+
style="background-color: {color};"
|
|
1068
|
+
onclick={() => setTextColor(color)}
|
|
1069
|
+
></button>
|
|
1070
|
+
{/each}
|
|
1071
|
+
</div>
|
|
1072
|
+
{/each}
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
{/if}
|
|
1076
|
+
</div>
|
|
1077
|
+
<div class={styles.colorButton}>
|
|
1078
|
+
<button
|
|
1079
|
+
type="button"
|
|
1080
|
+
class={styles.toolbarButton}
|
|
1081
|
+
onclick={() => { isBgColorOpen = !isBgColorOpen; isTextColorOpen = false; }}
|
|
1082
|
+
title="배경 색상"
|
|
1083
|
+
>
|
|
1084
|
+
<i class={styles.highlight}></i>
|
|
1085
|
+
</button>
|
|
1086
|
+
{#if isBgColorOpen}
|
|
1087
|
+
<div class={styles.colorDropdown}>
|
|
1088
|
+
<div class={styles.colorPalette}>
|
|
1089
|
+
{#each colorPalette as row}
|
|
1090
|
+
<div class={styles.colorRow}>
|
|
1091
|
+
{#each row as color}
|
|
1092
|
+
<button
|
|
1093
|
+
type="button"
|
|
1094
|
+
class={styles.colorCell}
|
|
1095
|
+
style="background-color: {color};"
|
|
1096
|
+
onclick={() => setBackgroundColor(color)}
|
|
1097
|
+
></button>
|
|
1098
|
+
{/each}
|
|
1099
|
+
</div>
|
|
1100
|
+
{/each}
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
{/if}
|
|
1104
|
+
</div>
|
|
1105
|
+
</div>
|
|
1106
|
+
{/if}
|
|
1107
|
+
|
|
1108
|
+
<!-- Align -->
|
|
1109
|
+
{#if isToolbarItemEnabled('align')}
|
|
1110
|
+
<div class={styles.toolbarGroup}>
|
|
1111
|
+
<div class={styles.alignButton}>
|
|
1112
|
+
<button
|
|
1113
|
+
type="button"
|
|
1114
|
+
class={styles.toolbarButton}
|
|
1115
|
+
onclick={() => isAlignDropdownOpen = !isAlignDropdownOpen}
|
|
1116
|
+
title={getCurrentAlignLabel()}
|
|
1117
|
+
>
|
|
1118
|
+
<span class={getCurrentAlignIcon()}></span>
|
|
1119
|
+
<i class={styles.dropdownArrow}></i>
|
|
1120
|
+
</button>
|
|
1121
|
+
{#if isAlignDropdownOpen}
|
|
1122
|
+
<div class={styles.alignDropdown}>
|
|
1123
|
+
{#each alignOptions as option}
|
|
1124
|
+
<button
|
|
1125
|
+
type="button"
|
|
1126
|
+
class={styles.alignOption}
|
|
1127
|
+
onclick={() => setAlignment(option.value)}
|
|
1128
|
+
>
|
|
1129
|
+
<span class={styles[option.icon]}></span>
|
|
1130
|
+
{option.label}
|
|
1131
|
+
</button>
|
|
1132
|
+
{/each}
|
|
1133
|
+
</div>
|
|
1134
|
+
{/if}
|
|
1135
|
+
</div>
|
|
1136
|
+
</div>
|
|
1137
|
+
{/if}
|
|
1138
|
+
|
|
1139
|
+
<!-- List -->
|
|
1140
|
+
{#if isToolbarItemEnabled('list')}
|
|
1141
|
+
<div class={styles.toolbarGroup}>
|
|
1142
|
+
<button type="button" class={styles.toolbarButton} onclick={() => insertList(false)} title="목록">
|
|
1143
|
+
<i class={styles.listUl}></i>
|
|
1144
|
+
</button>
|
|
1145
|
+
<button type="button" class={styles.toolbarButton} onclick={() => insertList(true)} title="번호 목록">
|
|
1146
|
+
<i class={styles.listOl}></i>
|
|
1147
|
+
</button>
|
|
1148
|
+
</div>
|
|
1149
|
+
{/if}
|
|
1150
|
+
|
|
1151
|
+
<!-- Table -->
|
|
1152
|
+
{#if isToolbarItemEnabled('table')}
|
|
1153
|
+
<div class={styles.toolbarGroup}>
|
|
1154
|
+
<div class={styles.tableButton}>
|
|
1155
|
+
<button
|
|
1156
|
+
type="button"
|
|
1157
|
+
class={styles.toolbarButton}
|
|
1158
|
+
onclick={() => {
|
|
1159
|
+
const selection = window.getSelection();
|
|
1160
|
+
if (selection && selection.rangeCount > 0) {
|
|
1161
|
+
savedTableSelection = selection.getRangeAt(0).cloneRange();
|
|
1162
|
+
}
|
|
1163
|
+
isTableDropdownOpen = !isTableDropdownOpen;
|
|
1164
|
+
}}
|
|
1165
|
+
title="표 삽입"
|
|
1166
|
+
>
|
|
1167
|
+
<i class={styles.table}></i>
|
|
1168
|
+
</button>
|
|
1169
|
+
{#if isTableDropdownOpen}
|
|
1170
|
+
<div class={styles.tableDropdown}>
|
|
1171
|
+
<div class={styles.tableGrid}>
|
|
1172
|
+
{#each Array(6) as _, row}
|
|
1173
|
+
<div class={styles.tableGridRow}>
|
|
1174
|
+
{#each Array(6) as _, col}
|
|
1175
|
+
<button
|
|
1176
|
+
type="button"
|
|
1177
|
+
class="{styles.tableGridCell} {row <= tableRows - 1 && col <= tableCols - 1 ? styles.selected : ''}"
|
|
1178
|
+
onmouseenter={() => { tableRows = row + 1; tableCols = col + 1; }}
|
|
1179
|
+
onclick={() => insertTable(row + 1, col + 1)}
|
|
1180
|
+
></button>
|
|
1181
|
+
{/each}
|
|
1182
|
+
</div>
|
|
1183
|
+
{/each}
|
|
1184
|
+
</div>
|
|
1185
|
+
<div class={styles.tableSize}>{tableRows} x {tableCols}</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
{/if}
|
|
1188
|
+
</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
{/if}
|
|
1191
|
+
|
|
1192
|
+
<!-- Link -->
|
|
1193
|
+
{#if isToolbarItemEnabled('link')}
|
|
1194
|
+
<div class={styles.toolbarGroup}>
|
|
1195
|
+
<div class={styles.linkButton}>
|
|
1196
|
+
<button
|
|
1197
|
+
type="button"
|
|
1198
|
+
class={styles.toolbarButton}
|
|
1199
|
+
onclick={() => {
|
|
1200
|
+
const selection = window.getSelection();
|
|
1201
|
+
if (selection && selection.rangeCount > 0) {
|
|
1202
|
+
savedSelection = selection.getRangeAt(0).cloneRange();
|
|
1203
|
+
}
|
|
1204
|
+
isLinkDropdownOpen = !isLinkDropdownOpen;
|
|
1205
|
+
}}
|
|
1206
|
+
title="링크 삽입"
|
|
1207
|
+
>
|
|
1208
|
+
<i class={styles.link}></i>
|
|
1209
|
+
</button>
|
|
1210
|
+
{#if isLinkDropdownOpen}
|
|
1211
|
+
<div class={styles.linkDropdown}>
|
|
1212
|
+
<div class={styles.linkInput}>
|
|
1213
|
+
<input
|
|
1214
|
+
type="text"
|
|
1215
|
+
placeholder="URL을 입력하세요"
|
|
1216
|
+
bind:value={linkUrl}
|
|
1217
|
+
onkeydown={(e) => e.key === 'Enter' && insertLink()}
|
|
1218
|
+
/>
|
|
1219
|
+
</div>
|
|
1220
|
+
<div class={styles.linkTarget}>
|
|
1221
|
+
<label>
|
|
1222
|
+
<input type="radio" bind:group={linkTarget} value="_blank" /> 새 탭
|
|
1223
|
+
</label>
|
|
1224
|
+
<label>
|
|
1225
|
+
<input type="radio" bind:group={linkTarget} value="_self" /> 현재 탭
|
|
1226
|
+
</label>
|
|
1227
|
+
</div>
|
|
1228
|
+
<button type="button" class={styles.linkInsertButton} onclick={insertLink}>
|
|
1229
|
+
삽입
|
|
1230
|
+
</button>
|
|
1231
|
+
</div>
|
|
1232
|
+
{/if}
|
|
1233
|
+
</div>
|
|
1234
|
+
</div>
|
|
1235
|
+
{/if}
|
|
1236
|
+
|
|
1237
|
+
<!-- HR -->
|
|
1238
|
+
{#if isToolbarItemEnabled('hr')}
|
|
1239
|
+
<div class={styles.toolbarGroup}>
|
|
1240
|
+
<button type="button" class={styles.toolbarButton} onclick={insertHR} title="구분선">
|
|
1241
|
+
<i class={styles.hr}></i>
|
|
1242
|
+
</button>
|
|
1243
|
+
</div>
|
|
1244
|
+
{/if}
|
|
1245
|
+
|
|
1246
|
+
<!-- Format clear -->
|
|
1247
|
+
{#if isToolbarItemEnabled('format')}
|
|
1248
|
+
<div class={styles.toolbarGroup}>
|
|
1249
|
+
<button type="button" class={styles.toolbarButton} onclick={removeFormat} title="서식 지우기">
|
|
1250
|
+
<i class={styles.eraser}></i>
|
|
1251
|
+
</button>
|
|
1252
|
+
</div>
|
|
1253
|
+
{/if}
|
|
1254
|
+
|
|
1255
|
+
<!-- Code view -->
|
|
1256
|
+
{#if isToolbarItemEnabled('code')}
|
|
1257
|
+
<div class={styles.toolbarGroup}>
|
|
1258
|
+
<button
|
|
1259
|
+
type="button"
|
|
1260
|
+
class="{styles.toolbarButton} {isCodeView ? styles.active : ''}"
|
|
1261
|
+
onclick={toggleCodeView}
|
|
1262
|
+
title="코드 보기"
|
|
1263
|
+
>
|
|
1264
|
+
<i class={styles.code}></i>
|
|
1265
|
+
</button>
|
|
1266
|
+
</div>
|
|
1267
|
+
{/if}
|
|
1268
|
+
</div>
|
|
1269
|
+
|
|
1270
|
+
<!-- Editor content -->
|
|
1271
|
+
<div class={styles.editorWrapper} style={editorStyle}>
|
|
1272
|
+
{#if !isCodeView}
|
|
1273
|
+
<div
|
|
1274
|
+
bind:this={editorRef}
|
|
1275
|
+
id={editorID}
|
|
1276
|
+
class={styles.editorContent}
|
|
1277
|
+
contenteditable="true"
|
|
1278
|
+
data-placeholder={placeholder}
|
|
1279
|
+
oninput={handleInput}
|
|
1280
|
+
oncompositionstart={handleCompositionStart}
|
|
1281
|
+
oncompositionend={handleCompositionEnd}
|
|
1282
|
+
onpaste={handlePaste}
|
|
1283
|
+
ondragover={handleDragOver}
|
|
1284
|
+
ondrop={handleDrop}
|
|
1285
|
+
onkeydown={handleKeyDown}
|
|
1286
|
+
role="textbox"
|
|
1287
|
+
aria-multiline="true"
|
|
1288
|
+
style={resizable ? 'resize: vertical;' : ''}
|
|
1289
|
+
></div>
|
|
1290
|
+
{:else}
|
|
1291
|
+
<textarea
|
|
1292
|
+
bind:this={codeEditorRef}
|
|
1293
|
+
class={styles.codeEditor}
|
|
1294
|
+
bind:value={codeContent}
|
|
1295
|
+
style="height: {savedEditorHeight}px;"
|
|
1296
|
+
></textarea>
|
|
1297
|
+
{/if}
|
|
1298
|
+
</div>
|
|
1299
|
+
|
|
1300
|
+
<!-- Validation message -->
|
|
1301
|
+
{#if validator && $message}
|
|
1302
|
+
<div class="validator" role="alert">
|
|
1303
|
+
{$message}
|
|
1304
|
+
</div>
|
|
1305
|
+
{/if}
|
|
1306
|
+
</div>
|
|
1307
|
+
|
|
1308
|
+
<style>
|
|
1309
|
+
:global(.validator) {
|
|
1310
|
+
color: var(--color-danger);
|
|
1311
|
+
font-size: 12px;
|
|
1312
|
+
margin-top: 4px;
|
|
1313
|
+
}
|
|
1314
|
+
</style>
|