@ssobig/writer-cli 0.3.2 → 0.3.3
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/package.json +1 -1
- package/templates/mystery-v1/authoring-view-preference.js +25 -2
- package/templates/mystery-v1/component-asset-operations.js +5 -4
- package/templates/mystery-v1/component-catalog-contract.js +2 -2
- package/templates/mystery-v1/component-renderers.js +1 -1
- package/templates/mystery-v1/markdown-document-model.js +15 -1
- package/templates/mystery-v1/markdown-image-editor.js +24 -8
- package/templates/mystery-v1/markdown-live-editor.js +275 -13
- package/templates/mystery-v1/markdown-toolbar.js +514 -0
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
package/package.json
CHANGED
|
@@ -7,8 +7,31 @@
|
|
|
7
7
|
|
|
8
8
|
const STORAGE_KEY = "somi-workbench-authoring-view";
|
|
9
9
|
const DEFAULT_VIEW = "preview";
|
|
10
|
-
const VIEWS = Object.freeze(["input", "preview"]);
|
|
10
|
+
const VIEWS = Object.freeze(["input", "preview", "list"]);
|
|
11
11
|
const STORAGE_VERSION = 1;
|
|
12
|
+
const PANEL_WIDTH_KEY = "somi-workbench-panel-width";
|
|
13
|
+
const PANEL_TEMPLATES = new Set(["ssobig.basic", "ssobig.progress", "ssobig.common", "ssobig.character", "ssobig.ending", "ssobig.author-notes"]);
|
|
14
|
+
|
|
15
|
+
function normalizePanelWidth(value) { return value === "wide" ? "wide" : "default"; }
|
|
16
|
+
function supportsPanelWidth(templateId) { return PANEL_TEMPLATES.has(templateId); }
|
|
17
|
+
function readPanelWidth(storage = deviceStorage()) {
|
|
18
|
+
try { return normalizePanelWidth(storage?.getItem(PANEL_WIDTH_KEY)); }
|
|
19
|
+
catch (_) { return "default"; }
|
|
20
|
+
}
|
|
21
|
+
function writePanelWidth(value, storage = deviceStorage()) {
|
|
22
|
+
const width = normalizePanelWidth(value);
|
|
23
|
+
try { storage?.setItem(PANEL_WIDTH_KEY, width); }
|
|
24
|
+
catch (_) { /* 보기 설정을 저장할 수 없어도 현재 화면에는 적용한다. */ }
|
|
25
|
+
return width;
|
|
26
|
+
}
|
|
27
|
+
function panelWidthControl(value) {
|
|
28
|
+
const width = normalizePanelWidth(value);
|
|
29
|
+
const options = [
|
|
30
|
+
["default", "기본", "M2 12h7m-4-4 4 4-4 4M22 12h-7m4-4-4 4 4 4"],
|
|
31
|
+
["wide", "와이드", "M3 12h18M7 8l-4 4 4 4M17 8l4 4-4 4"]
|
|
32
|
+
];
|
|
33
|
+
return `<div class="panel-width-buttons" role="group" aria-label="패널 폭">${options.map(([id, label, icon]) => `<button type="button" data-panel-width="${id}" aria-pressed="${width === id}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="${icon}"/></svg><span>${label}</span></button>`).join("")}</div>`;
|
|
34
|
+
}
|
|
12
35
|
|
|
13
36
|
function normalize(value) {
|
|
14
37
|
return VIEWS.includes(value) ? value : DEFAULT_VIEW;
|
|
@@ -47,5 +70,5 @@
|
|
|
47
70
|
return normalized;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
|
-
return Object.freeze({ STORAGE_KEY, STORAGE_VERSION, DEFAULT_VIEW, VIEWS, normalize, read, write });
|
|
73
|
+
return Object.freeze({ STORAGE_KEY, STORAGE_VERSION, DEFAULT_VIEW, VIEWS, normalize, read, write, PANEL_WIDTH_KEY, normalizePanelWidth, supportsPanelWidth, readPanelWidth, writePanelWidth, panelWidthControl });
|
|
51
74
|
});
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
const clueInstance = active.find(instance => instance.templateId === "ssobig.clues");
|
|
29
29
|
const clueData = dataFor(clueInstance) || clueInstance?.data;
|
|
30
30
|
for (const [index, clue] of (Array.isArray(clueData?.clues) ? clueData.clues : []).entries()) {
|
|
31
|
-
const assetId = clueAssetId(clue);
|
|
32
|
-
if (!assetId) continue;
|
|
33
|
-
if (!result[assetId]) result[assetId] = [];
|
|
34
31
|
const label = clue.name || clue.title || clue.id || index + 1;
|
|
35
|
-
|
|
32
|
+
for (const [side, sideLabel] of [["back", "단서 이미지(앞면)"], ["front", "단서 이미지(뒷면)"]]) {
|
|
33
|
+
const assetId = clueAssetId(clue, side);
|
|
34
|
+
if (!assetId) continue;
|
|
35
|
+
(result[assetId] ||= []).push(`단서 · ${label} · ${sideLabel}`);
|
|
36
|
+
}
|
|
36
37
|
}
|
|
37
38
|
for (const instance of active) {
|
|
38
39
|
if (instance.templateId === "ssobig.assets") continue;
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
guide: guide("모든 참가자가 공통으로 읽는 규칙과 배경 정보를 작성합니다.", "프롤로그, 조사 규칙, 사건의 전말과 에필로그를 본문과 펼쳐보기로 구성합니다.", "문서의 제목·본문·노출 순서와 문서별 펼쳐보기 항목을 편집합니다.", "integrated", "공통 문서는 펼쳐보기 항목을 포함해 모든 캐릭터 관점의 통합 미리보기에 함께 표시됩니다.", ["ssobig.progress", "ssobig.character", "ssobig.ending"], "공통 문서 제작 화면 예시"),
|
|
57
57
|
defaultData: { documents: [], deletedDocumentIds: [] },
|
|
58
58
|
view: view("ssobig.view.common-workbench", "common-workbench", "공통정보 작업 화면", "ssobig.common", [
|
|
59
|
-
binding("primary", "ssobig.common", "read_write")
|
|
59
|
+
binding("primary", "ssobig.common", "read_write"), binding("assets", "ssobig.assets", "read_write")
|
|
60
60
|
]),
|
|
61
61
|
importProfiles: [IMPORT_PROFILE]
|
|
62
62
|
},
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
guide: guide("플레이어에게 공개하지 않을 제작·운영 메모를 보관합니다.", "복선 점검, 진행자 주의사항과 다음 수정 목록을 기록합니다.", "제목과 본문으로 된 자유 메모를 추가하고 정리합니다.", "authoring", "작가 전용 Component로 통합 미리보기에 표시되지 않습니다.", [], "작가노트 제작 화면 예시"),
|
|
126
126
|
defaultData: { notes: [] },
|
|
127
127
|
view: view("ssobig.view.author-notes-workbench", "author-notes-workbench", "작가노트 작업 화면", "ssobig.author-notes", [
|
|
128
|
-
binding("primary", "ssobig.author-notes", "read_write")
|
|
128
|
+
binding("primary", "ssobig.author-notes", "read_write"), binding("assets", "ssobig.assets", "read_write")
|
|
129
129
|
]),
|
|
130
130
|
importProfiles: [IMPORT_PROFILE]
|
|
131
131
|
},
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
}
|
|
89
89
|
function applyRendererView(markup, view) {
|
|
90
90
|
return String(markup)
|
|
91
|
-
.replace(/data-renderer-view="(?:input|preview)"/, `data-renderer-view="${view}"`);
|
|
91
|
+
.replace(/data-renderer-view="(?:input|preview|list)"/, `data-renderer-view="${view}"`);
|
|
92
92
|
}
|
|
93
93
|
function renderAuthoring(instance, data, extra) {
|
|
94
94
|
const renderer = resolve(instance);
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
function parseLine(text, index = 0, from = 0) {
|
|
36
36
|
const source = String(text ?? "");
|
|
37
|
+
const horizontalRule = /^ {0,3}-(?:[ \t]*-){2,}[ \t]*$/.test(source);
|
|
37
38
|
const heading = source.match(/^( {0,3})(#{1,4})(?:[ \t]+)(.*)$/);
|
|
38
39
|
const quote = source.match(/^( {0,3})>[ \t]?(.*)$/);
|
|
39
40
|
const unordered = source.match(/^( {0,3})([-*])([ \t]+)(.*)$/);
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
const body = unordered ? unordered[4] : ordered ? ordered[5] : "";
|
|
47
48
|
const markerFrom = list ? indent.length : -1;
|
|
48
49
|
const markerTo = list ? markerFrom + marker.length : -1;
|
|
50
|
+
const task = unordered ? body.match(/^\[([ xX])\](?:[ \t]+|$)/) : null;
|
|
49
51
|
|
|
50
52
|
return Object.freeze({
|
|
51
53
|
index,
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
from,
|
|
54
56
|
to: from + source.length,
|
|
55
57
|
empty: !source.trim(),
|
|
58
|
+
horizontalRule,
|
|
56
59
|
headingLevel: heading ? heading[2].length : 0,
|
|
57
60
|
headingMarkerFrom: heading ? heading[1].length : -1,
|
|
58
61
|
headingMarkerTo: heading ? source.length - heading[3].length : -1,
|
|
@@ -68,7 +71,10 @@
|
|
|
68
71
|
listMarkerTo: markerTo,
|
|
69
72
|
listPrefixTo: list ? markerTo + separator.length : -1,
|
|
70
73
|
listSeparator: separator,
|
|
71
|
-
listBody: body,
|
|
74
|
+
listBody: task ? body.slice(task[0].length) : body,
|
|
75
|
+
taskChecked: task ? task[1].toLowerCase() === "x" : null,
|
|
76
|
+
taskStateFrom: task ? markerTo + separator.length + 1 : -1,
|
|
77
|
+
taskMarkerTo: task ? markerTo + separator.length + 3 : -1,
|
|
72
78
|
listNumberText: ordered ? ordered[2] : "",
|
|
73
79
|
listNumber: ordered ? Number.parseInt(ordered[2], 10) : null,
|
|
74
80
|
listDelimiter: ordered ? ordered[3] : unordered ? unordered[2] : ""
|
|
@@ -360,6 +366,13 @@
|
|
|
360
366
|
continue;
|
|
361
367
|
}
|
|
362
368
|
|
|
369
|
+
if (line.horizontalRule) {
|
|
370
|
+
closeAll();
|
|
371
|
+
blocks.push({ type: "horizontalRule", lineIndex: line.index });
|
|
372
|
+
lineIndex += 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
363
376
|
if (line.quoteMarkerFrom >= 0) {
|
|
364
377
|
closeParagraph();
|
|
365
378
|
if (list?.items.length) {
|
|
@@ -408,6 +421,7 @@
|
|
|
408
421
|
indent: line.listIndent,
|
|
409
422
|
separator: line.listSeparator,
|
|
410
423
|
body: line.listBody,
|
|
424
|
+
taskChecked: line.taskChecked,
|
|
411
425
|
numberText: line.listNumberText,
|
|
412
426
|
lineIndex: line.index,
|
|
413
427
|
children: []
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const doc = parent.ownerDocument;
|
|
19
19
|
let view = null;
|
|
20
20
|
let disposed = false;
|
|
21
|
+
let picker = null;
|
|
21
22
|
let serial = Promise.resolve();
|
|
22
23
|
const jobs = new Map();
|
|
23
24
|
const fileInputs = new Set();
|
|
@@ -243,13 +244,24 @@
|
|
|
243
244
|
input.click();
|
|
244
245
|
}
|
|
245
246
|
function chooseAsset() {
|
|
247
|
+
if (picker || disposed) return;
|
|
246
248
|
const mark = { from: view.state.selection.main.head, to: view.state.selection.main.head };
|
|
247
249
|
if (!allowed(mark)) return;
|
|
248
250
|
const id = addBookmark(mark);
|
|
249
251
|
const dialog = doc.createElement("dialog");
|
|
252
|
+
picker = dialog;
|
|
250
253
|
dialog.className = "writer-md-image-picker";
|
|
251
|
-
dialog.setAttribute("aria-label", "
|
|
252
|
-
|
|
254
|
+
dialog.setAttribute("aria-label", "이미지 삽입");
|
|
255
|
+
if (assets?.upload) {
|
|
256
|
+
const find = button("이미지 찾기", () => {
|
|
257
|
+
const current = view.state.field(bookmarks).get(id);
|
|
258
|
+
if (current) chooseFiles(current);
|
|
259
|
+
dialog.close();
|
|
260
|
+
});
|
|
261
|
+
find.className = "writer-md-image-picker-upload";
|
|
262
|
+
dialog.append(find);
|
|
263
|
+
}
|
|
264
|
+
const heading = doc.createElement("h3"); heading.textContent = "에셋 리스트";
|
|
253
265
|
dialog.append(heading);
|
|
254
266
|
const list = doc.createElement("div"); list.className = "writer-md-image-picker-list";
|
|
255
267
|
const entries = (assets.list?.() || []).filter(entry => IMAGE_TYPES.includes(entry.type));
|
|
@@ -262,9 +274,9 @@
|
|
|
262
274
|
if (url) { const image = doc.createElement("img"); image.src = url; image.alt = ""; item.prepend(image); }
|
|
263
275
|
list.append(item);
|
|
264
276
|
});
|
|
265
|
-
if (!entries.length) list.textContent = "등록된 이미지가 없습니다. 이미지
|
|
277
|
+
if (!entries.length) list.textContent = "등록된 이미지가 없습니다. 위의 이미지 찾기로 추가해주세요.";
|
|
266
278
|
dialog.append(list, button("닫기", () => dialog.close()));
|
|
267
|
-
dialog.addEventListener("close", () => { dialog.remove(); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); view.focus(); } }, { once: true });
|
|
279
|
+
dialog.addEventListener("close", () => { picker = null; dialog.remove(); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); view.focus(); onIdle?.(); } }, { once: true });
|
|
268
280
|
parent.append(dialog);
|
|
269
281
|
dialog.showModal();
|
|
270
282
|
}
|
|
@@ -300,7 +312,9 @@
|
|
|
300
312
|
};
|
|
301
313
|
}
|
|
302
314
|
return {
|
|
303
|
-
|
|
315
|
+
chooseFiles,
|
|
316
|
+
chooseAsset,
|
|
317
|
+
hasPending: () => jobs.size > 0 || fileInputs.size > 0 || picker !== null,
|
|
304
318
|
extensions: [bookmarks, decorations, events, cm.keymap.of([
|
|
305
319
|
{ key: "Backspace", run: protectBoundary(true) },
|
|
306
320
|
{ key: "Delete", run: protectBoundary(false) }
|
|
@@ -308,12 +322,14 @@
|
|
|
308
322
|
attach(editor) {
|
|
309
323
|
view = editor;
|
|
310
324
|
if (assets?.upload) {
|
|
311
|
-
|
|
312
|
-
|
|
325
|
+
if (!doc.defaultView.WriterMarkdownToolbar) {
|
|
326
|
+
toolbar.append(button("이미지 업로드", () => chooseFiles()), button("에셋에서 선택", chooseAsset));
|
|
327
|
+
parent.prepend(toolbar);
|
|
328
|
+
}
|
|
313
329
|
parent.append(feedback);
|
|
314
330
|
}
|
|
315
331
|
},
|
|
316
|
-
destroy() { disposed = true; jobs.clear(); fileInputs.forEach(input => input.remove()); fileInputs.clear(); toolbar.remove(); feedback.remove(); parent.querySelectorAll(".writer-md-image-picker").forEach(dialog => dialog.remove()); }
|
|
332
|
+
destroy() { disposed = true; picker = null; jobs.clear(); fileInputs.forEach(input => input.remove()); fileInputs.clear(); toolbar.remove(); feedback.remove(); parent.querySelectorAll(".writer-md-image-picker").forEach(dialog => dialog.remove()); }
|
|
317
333
|
};
|
|
318
334
|
}
|
|
319
335
|
return Object.freeze({ create, insertion });
|
|
@@ -2,17 +2,22 @@
|
|
|
2
2
|
const commonJs = typeof module === "object" && module.exports;
|
|
3
3
|
const CodeMirror6 = commonJs ? null : root?.WriterCodeMirror6;
|
|
4
4
|
const markdownDocumentModel = commonJs ? require("./markdown-document-model.js") : root?.WriterMarkdownDocumentModel;
|
|
5
|
-
const
|
|
5
|
+
const toolbarApi = commonJs ? require("./markdown-toolbar.js") : root?.WriterMarkdownToolbar;
|
|
6
|
+
const api = factory(CodeMirror6, markdownDocumentModel, toolbarApi);
|
|
6
7
|
if (commonJs) module.exports = api;
|
|
7
8
|
if (root) root.WriterMarkdownLiveEditor = api;
|
|
8
|
-
})(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel) {
|
|
9
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6, markdownDocumentModel, toolbarApi) {
|
|
9
10
|
"use strict";
|
|
10
11
|
|
|
11
12
|
const INLINE_MARKERS = Object.freeze([
|
|
13
|
+
{ marker: "<u>", close: "</u>", type: "underline" },
|
|
14
|
+
{ marker: "***", type: "strong-emphasis" },
|
|
12
15
|
{ marker: "++", type: "brand" },
|
|
13
16
|
{ marker: "**", type: "strong" },
|
|
14
17
|
{ marker: "~~", type: "strikethrough" },
|
|
15
|
-
{ marker: "==", type: "highlight" }
|
|
18
|
+
{ marker: "==", type: "highlight" },
|
|
19
|
+
{ marker: "*", type: "emphasis" },
|
|
20
|
+
{ marker: "_", type: "emphasis" }
|
|
16
21
|
]);
|
|
17
22
|
function isEscapedAt(source, index) {
|
|
18
23
|
let backslashes = 0;
|
|
@@ -113,19 +118,25 @@
|
|
|
113
118
|
continue;
|
|
114
119
|
}
|
|
115
120
|
const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
|
|
116
|
-
if (
|
|
121
|
+
if (definition?.marker === "_" && /[\p{L}\p{N}_]/u.test(source[index - 1] || "")) { index++; continue; }
|
|
122
|
+
if (definition?.marker === "*" && (source[index-1] === "*" || source[index+1] === "*")) { index++; continue; }
|
|
123
|
+
if (!definition || isEscapedAt(source, index)) {
|
|
117
124
|
index += 1;
|
|
118
125
|
continue;
|
|
119
126
|
}
|
|
120
127
|
const contentFrom = index + definition.marker.length;
|
|
121
|
-
const
|
|
128
|
+
const closeMarker = definition.close || definition.marker;
|
|
129
|
+
let closeFrom = source.indexOf(closeMarker, contentFrom);
|
|
130
|
+
while (closeFrom >= 0 && (isEscapedAt(source, closeFrom) || (definition.marker === "_" && /[\p{L}\p{N}_]/u.test(source[closeFrom + 1] || "")))) {
|
|
131
|
+
closeFrom = source.indexOf(closeMarker, closeFrom + closeMarker.length);
|
|
132
|
+
}
|
|
122
133
|
const crossesBlockBoundary = closeFrom > contentFrom && /\n[ \t]*\n/.test(source.slice(contentFrom, closeFrom));
|
|
123
134
|
if (closeFrom <= contentFrom || crossesBlockBoundary || (closeFrom > 0 && source[closeFrom - 1] === "\\")) {
|
|
124
135
|
index += definition.marker.length;
|
|
125
136
|
continue;
|
|
126
137
|
}
|
|
127
|
-
const to = closeFrom +
|
|
128
|
-
|
|
138
|
+
const to = closeFrom + closeMarker.length;
|
|
139
|
+
const token = {
|
|
129
140
|
type: definition.type,
|
|
130
141
|
marker: definition.marker,
|
|
131
142
|
from: index,
|
|
@@ -134,7 +145,11 @@
|
|
|
134
145
|
contentTo: closeFrom,
|
|
135
146
|
closeFrom,
|
|
136
147
|
to
|
|
137
|
-
}
|
|
148
|
+
};
|
|
149
|
+
if (definition.type === "strong-emphasis") {
|
|
150
|
+
tokens.push({ ...token, type: "strong", marker: "**", openTo: index + 2, contentFrom: index + 2, contentTo: to - 2, closeFrom: to - 2 });
|
|
151
|
+
tokens.push({ ...token, type: "emphasis", marker: "*", from: index + 2, to: to - 2 });
|
|
152
|
+
} else tokens.push(token);
|
|
138
153
|
index = to;
|
|
139
154
|
}
|
|
140
155
|
return tokens;
|
|
@@ -149,6 +164,45 @@
|
|
|
149
164
|
});
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
const characterSegmenter = typeof Intl.Segmenter === "function"
|
|
168
|
+
? new Intl.Segmenter("ko", { granularity: "grapheme" }) : null;
|
|
169
|
+
|
|
170
|
+
// Count the document model, never CodeMirror's virtualized/selected DOM.
|
|
171
|
+
function characterCount(value) {
|
|
172
|
+
const documentModel = markdownDocumentModel.parseDocument(value, { preserveBlankLines: true });
|
|
173
|
+
const fragments = [];
|
|
174
|
+
const visit = blocks => blocks.forEach(block => {
|
|
175
|
+
if (block.type === "paragraph" || block.type === "quote") fragments.push(block.lines.join("\n"));
|
|
176
|
+
else if (block.type === "heading") fragments.push(block.text);
|
|
177
|
+
else if (block.type === "blank") fragments.push(documentModel.lines[block.lineIndex].text);
|
|
178
|
+
else if (block.type === "list") block.items.forEach(item => {
|
|
179
|
+
fragments.push(item.body);
|
|
180
|
+
visit(item.children);
|
|
181
|
+
});
|
|
182
|
+
else if (block.type === "table") [block.header, ...block.rows].forEach(row => {
|
|
183
|
+
row.cells.forEach(cell => fragments.push(cell.text));
|
|
184
|
+
});
|
|
185
|
+
// Images and horizontal rules contain no manuscript characters.
|
|
186
|
+
});
|
|
187
|
+
visit(documentModel.blocks);
|
|
188
|
+
return fragments.reduce((total, text) => {
|
|
189
|
+
const hidden = inlineTokens(text).flatMap(token => [
|
|
190
|
+
{ from: token.from, to: token.openTo },
|
|
191
|
+
{ from: token.closeFrom, to: token.to }
|
|
192
|
+
]).sort((a, b) => a.from - b.from);
|
|
193
|
+
let cursor = 0;
|
|
194
|
+
let visible = "";
|
|
195
|
+
hidden.forEach(range => {
|
|
196
|
+
if (range.from > cursor) visible += text.slice(cursor, range.from);
|
|
197
|
+
cursor = Math.max(cursor, range.to);
|
|
198
|
+
});
|
|
199
|
+
visible = (visible + text.slice(cursor)).replace(/\r?\n/g, "");
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (const ignored of characterSegmenter ? characterSegmenter.segment(visible) : visible) count++;
|
|
202
|
+
return total + count;
|
|
203
|
+
}, 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
152
206
|
function selectionTouchesRange(selection, from, to) {
|
|
153
207
|
return selection.from === selection.to
|
|
154
208
|
? selection.head >= from && selection.head <= to
|
|
@@ -225,6 +279,10 @@
|
|
|
225
279
|
};
|
|
226
280
|
documentModel.blocks.forEach(sourceBlock => {
|
|
227
281
|
if (sourceBlock.type === "blank") return;
|
|
282
|
+
if (sourceBlock.type === "horizontalRule") {
|
|
283
|
+
assignBlock("horizontalRule", [sourceBlock.lineIndex]);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
228
286
|
if (sourceBlock.type === "image") {
|
|
229
287
|
assignBlock("image", [sourceBlock.lineIndex], { assetId: sourceBlock.assetId });
|
|
230
288
|
return;
|
|
@@ -532,6 +590,30 @@
|
|
|
532
590
|
return values.join(" ");
|
|
533
591
|
}
|
|
534
592
|
|
|
593
|
+
function tableOperation(value, selection, command, dimensions = {}) {
|
|
594
|
+
const source = String(value);
|
|
595
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
596
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
597
|
+
const blocks = markdownDocumentModel.parseDocument(source).blocks;
|
|
598
|
+
const table = blocks.find(block => block.type === "table" && from >= block.from && to <= block.to);
|
|
599
|
+
if (command === "table-delete") {
|
|
600
|
+
if (!table) return null;
|
|
601
|
+
// Replace only the table, never adjacent paragraphs or blank lines.
|
|
602
|
+
return { changes: { from: table.from, to: table.to, insert: "" }, selection: { anchor: table.from } };
|
|
603
|
+
}
|
|
604
|
+
if (command !== "table") return null;
|
|
605
|
+
if (!table && blocks.some(block => ["table", "code", "image"].includes(block.type) && from <= block.to && to >= block.from)) return null;
|
|
606
|
+
const columns = dimensions.columns ?? 2;
|
|
607
|
+
const rows = dimensions.rows ?? 3;
|
|
608
|
+
if (!Number.isInteger(columns) || columns < 1 || columns > 12 || !Number.isInteger(rows) || rows < 1 || rows > 30) return null;
|
|
609
|
+
const row = values => `| ${values.join(" | ")} |`;
|
|
610
|
+
const text = [row(Array.from({ length: columns }, (_, index) => `제목 ${index + 1}`)), row(Array(columns).fill("---")), ...Array.from({ length: rows }, () => row(Array(columns).fill("")))].join("\n");
|
|
611
|
+
const position = table ? table.to : to;
|
|
612
|
+
const prefix = position && !source.slice(0, position).endsWith("\n\n") ? (source[position - 1] === "\n" ? "\n" : "\n\n") : "";
|
|
613
|
+
const suffix = source.slice(position).startsWith("\n\n") ? "" : source[position] === "\n" ? "\n" : "\n\n";
|
|
614
|
+
return { changes: { from: position, to: position, insert: prefix + text + suffix }, selection: { anchor: position + prefix.length + 2 } };
|
|
615
|
+
}
|
|
616
|
+
|
|
535
617
|
function mount(options = {}) {
|
|
536
618
|
const cm = CodeMirror6;
|
|
537
619
|
if (!cm?.EditorState || !cm?.StateEffect || !cm?.EditorView || !cm?.Decoration || !cm?.StateField) {
|
|
@@ -539,8 +621,22 @@
|
|
|
539
621
|
}
|
|
540
622
|
if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
|
|
541
623
|
let destroyed = false;
|
|
624
|
+
let toolbar = null;
|
|
542
625
|
const ownerDocument = options.parent.ownerDocument || document;
|
|
626
|
+
const counter = ownerDocument.createElement("div");
|
|
627
|
+
counter.className = "writer-markdown-character-count";
|
|
628
|
+
const updateCounter = doc => {
|
|
629
|
+
const label = `${characterCount(doc.toString()).toLocaleString("ko-KR")}자`;
|
|
630
|
+
if (counter.textContent !== label) counter.textContent = label;
|
|
631
|
+
};
|
|
543
632
|
const editableEffect = cm.StateEffect.define();
|
|
633
|
+
const tableOperationEffect = cm.StateEffect.define();
|
|
634
|
+
const tableHistoryBoundary = cm.StateField.define({
|
|
635
|
+
create: () => false,
|
|
636
|
+
update(value, transaction) {
|
|
637
|
+
return transaction.docChanged ? transaction.effects.some(effect => effect.is(tableOperationEffect)) : value;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
544
640
|
const editableField = cm.StateField.define({
|
|
545
641
|
create: () => options.readOnly !== true,
|
|
546
642
|
update(value, transaction) {
|
|
@@ -553,7 +649,7 @@
|
|
|
553
649
|
const images = imageEditor?.create({
|
|
554
650
|
cm, model: markdownDocumentModel, assets: options.assets, parent: options.parent,
|
|
555
651
|
onIdle: () => setTimeout(() => {
|
|
556
|
-
if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
|
|
652
|
+
if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
|
|
557
653
|
}, 0),
|
|
558
654
|
canInsert: (source, from, to) => !markdownDocumentModel.parseDocument(source).blocks.some(block => block.type === "table" && from <= block.to && to >= block.from)
|
|
559
655
|
});
|
|
@@ -568,6 +664,35 @@
|
|
|
568
664
|
return focused;
|
|
569
665
|
}
|
|
570
666
|
});
|
|
667
|
+
class TaskCheckbox {
|
|
668
|
+
constructor(from, checked, disabled) { this.from = from; this.checked = checked; this.disabled = disabled; }
|
|
669
|
+
eq(other) { return this.from === other.from && this.checked === other.checked && this.disabled === other.disabled; }
|
|
670
|
+
compare(other) { return this === other || this.eq(other); }
|
|
671
|
+
get estimatedHeight() { return -1; }
|
|
672
|
+
get lineBreaks() { return 0; }
|
|
673
|
+
updateDOM() { return false; }
|
|
674
|
+
coordsAt() { return null; }
|
|
675
|
+
destroy() {}
|
|
676
|
+
toDOM(view) {
|
|
677
|
+
const marker = ownerDocument.createElement("span");
|
|
678
|
+
marker.className = "writer-md-list-marker";
|
|
679
|
+
const input = ownerDocument.createElement("input");
|
|
680
|
+
input.type = "checkbox";
|
|
681
|
+
input.className = "writer-md-task-checkbox";
|
|
682
|
+
input.setAttribute("aria-label", "할 일 완료");
|
|
683
|
+
input.checked = this.checked;
|
|
684
|
+
input.disabled = this.disabled;
|
|
685
|
+
input.addEventListener("mousedown", event => event.preventDefault());
|
|
686
|
+
input.addEventListener("change", () => {
|
|
687
|
+
if (view.state.readOnly || view.composing || view.compositionStarted) { input.checked = this.checked; return; }
|
|
688
|
+
view.dispatch({ changes: { from: this.from, to: this.from + 1, insert: input.checked ? "x" : " " }, userEvent: "input.task" });
|
|
689
|
+
view.focus();
|
|
690
|
+
});
|
|
691
|
+
marker.append(input);
|
|
692
|
+
return marker;
|
|
693
|
+
}
|
|
694
|
+
ignoreEvent() { return true; }
|
|
695
|
+
}
|
|
571
696
|
const buildDecorations = state => {
|
|
572
697
|
const selections = state.field(focusField)
|
|
573
698
|
? state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head }))
|
|
@@ -589,6 +714,10 @@
|
|
|
589
714
|
attributes.style = `--writer-md-table-column-count:${columnCount};--writer-md-table-column-width:${columnWidth}%;--writer-md-table-min-width:${minimumWidth}em`;
|
|
590
715
|
}
|
|
591
716
|
ranges.push(cm.Decoration.line({ attributes }).range(line.from));
|
|
717
|
+
if (line.blockType === "horizontalRule") {
|
|
718
|
+
ranges.push(syntaxDecoration(line.active).range(line.from, line.to));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
592
721
|
if (line.blockType === "table") {
|
|
593
722
|
line.tableCells.forEach((cell, columnIndex) => {
|
|
594
723
|
if (cell.rawFrom >= cell.rawTo) return;
|
|
@@ -646,7 +775,10 @@
|
|
|
646
775
|
inclusiveEnd: false
|
|
647
776
|
}).range(from, to));
|
|
648
777
|
}
|
|
649
|
-
if (line.
|
|
778
|
+
if (line.taskStateFrom >= 0 && line.blockType === "listItem") {
|
|
779
|
+
ranges.push(cm.Decoration.widget({ widget: new TaskCheckbox(line.from + line.taskStateFrom, line.taskChecked, state.readOnly), side: -1 }).range(line.from + line.listMarkerFrom));
|
|
780
|
+
ranges.push(syntaxDecoration(false).range(line.from + line.listMarkerFrom, line.from + line.taskMarkerTo));
|
|
781
|
+
} else if (line.listMarkerFrom >= 0) {
|
|
650
782
|
const from = line.from + line.listMarkerFrom;
|
|
651
783
|
const to = line.from + line.listMarkerTo;
|
|
652
784
|
ranges.push(cm.Decoration.mark({
|
|
@@ -669,7 +801,8 @@
|
|
|
669
801
|
},
|
|
670
802
|
update(decorations, transaction) {
|
|
671
803
|
const focusChanged = transaction.effects.some(effect => effect.is(focusEffect));
|
|
672
|
-
|
|
804
|
+
const editableChanged = transaction.effects.some(effect => effect.is(editableEffect));
|
|
805
|
+
return transaction.docChanged || transaction.selection || focusChanged || editableChanged ? buildDecorations(transaction.state) : decorations;
|
|
673
806
|
},
|
|
674
807
|
provide: field => cm.EditorView.decorations.from(field)
|
|
675
808
|
});
|
|
@@ -1002,6 +1135,16 @@
|
|
|
1002
1135
|
if (!transaction.docChanged) return true;
|
|
1003
1136
|
if (transaction.isUserEvent?.("undo") || transaction.isUserEvent?.("redo")) return true;
|
|
1004
1137
|
const source = transaction.startState.doc.toString();
|
|
1138
|
+
const tableOperations = transaction.effects.filter(effect => effect.is(tableOperationEffect));
|
|
1139
|
+
if (tableOperations.length) {
|
|
1140
|
+
if (tableOperations.length !== 1 || transaction.startState.readOnly) return false;
|
|
1141
|
+
const request = tableOperations[0].value;
|
|
1142
|
+
const expected = tableOperation(source, transaction.startState.selection.main, request.command, request.dimensions);
|
|
1143
|
+
if (!expected) return false;
|
|
1144
|
+
const actual = [];
|
|
1145
|
+
transaction.changes.iterChanges((from, to, _fromB, _toB, inserted) => actual.push({ from, to, insert: inserted.toString() }));
|
|
1146
|
+
return actual.length === 1 && actual[0].from === expected.changes.from && actual[0].to === expected.changes.to && actual[0].insert === expected.changes.insert;
|
|
1147
|
+
}
|
|
1005
1148
|
const tables = tableCellContexts(source);
|
|
1006
1149
|
if (!tables.length) return true;
|
|
1007
1150
|
const changes = [];
|
|
@@ -1097,7 +1240,12 @@
|
|
|
1097
1240
|
cm.EditorState.changeFilter.of(transaction => !transaction.startState.readOnly),
|
|
1098
1241
|
...(images?.extensions || []),
|
|
1099
1242
|
cm.EditorView.editorAttributes.of({ class: "writer-markdown-live-editor" }),
|
|
1100
|
-
|
|
1243
|
+
tableHistoryBoundary,
|
|
1244
|
+
cm.history({
|
|
1245
|
+
joinToEvent: (transaction, adjacent) => adjacent
|
|
1246
|
+
&& !transaction.startState.field(tableHistoryBoundary)
|
|
1247
|
+
&& !transaction.effects.some(effect => effect.is(tableOperationEffect))
|
|
1248
|
+
}),
|
|
1101
1249
|
cm.keymap.of([
|
|
1102
1250
|
{ key: "Escape", run() { options.onEscape?.(); return true; } },
|
|
1103
1251
|
{ key: "Enter", run: handleEnter },
|
|
@@ -1126,7 +1274,9 @@
|
|
|
1126
1274
|
"aria-label": String(options.ariaLabel || "Markdown 편집")
|
|
1127
1275
|
}),
|
|
1128
1276
|
cm.EditorView.updateListener.of(update => {
|
|
1277
|
+
if (update.docChanged) updateCounter(update.state.doc);
|
|
1129
1278
|
if (update.docChanged && !replacingValue) options.onChange?.(update.state.doc.toString());
|
|
1279
|
+
toolbar?.refresh();
|
|
1130
1280
|
}),
|
|
1131
1281
|
cm.EditorView.domEventHandlers({
|
|
1132
1282
|
mousedown: handleTableCellMouseDown,
|
|
@@ -1139,14 +1289,119 @@
|
|
|
1139
1289
|
blur() {
|
|
1140
1290
|
view.dispatch({ effects: focusEffect.of(false) });
|
|
1141
1291
|
setTimeout(() => {
|
|
1142
|
-
if (!destroyed && !images?.hasPending() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
|
|
1292
|
+
if (!destroyed && !images?.hasPending() && !toolbar?.ownsFocus() && !options.parent.contains(ownerDocument.activeElement)) options.onBlur?.();
|
|
1143
1293
|
}, 0);
|
|
1144
1294
|
}
|
|
1145
1295
|
})
|
|
1146
1296
|
]
|
|
1147
1297
|
});
|
|
1148
1298
|
const view = new cm.EditorView({ state, parent: options.parent });
|
|
1299
|
+
updateCounter(state.doc);
|
|
1300
|
+
options.parent.appendChild(counter);
|
|
1149
1301
|
images?.attach(view);
|
|
1302
|
+
let toolbarDocument = null;
|
|
1303
|
+
let toolbarParsed = null;
|
|
1304
|
+
let toolbarState = null;
|
|
1305
|
+
let toolbarContext = null;
|
|
1306
|
+
const historyCommands = {
|
|
1307
|
+
undo: cm.historyKeymap.find(binding => binding.key === "Mod-z")?.run,
|
|
1308
|
+
redo: cm.historyKeymap.find(binding => binding.key === "Mod-y")?.run
|
|
1309
|
+
|| cm.historyKeymap.find(binding => binding.key === "Mod-z")?.shift
|
|
1310
|
+
};
|
|
1311
|
+
const formatContext = () => {
|
|
1312
|
+
if (destroyed || view.state.readOnly) return null;
|
|
1313
|
+
if (toolbarState === view.state) return toolbarContext;
|
|
1314
|
+
// Share one parse across buttons and reuse it while only the selection moves.
|
|
1315
|
+
if (toolbarDocument !== view.state.doc) {
|
|
1316
|
+
toolbarDocument = view.state.doc;
|
|
1317
|
+
const source = toolbarDocument.toString();
|
|
1318
|
+
toolbarParsed = { source, tokens: inlineTokens(source), blocks: markdownDocumentModel.parseDocument(source).blocks };
|
|
1319
|
+
}
|
|
1320
|
+
const selection = view.state.selection.main;
|
|
1321
|
+
const multiline = toolbarParsed.source.slice(selection.from, selection.to).includes("\n");
|
|
1322
|
+
const parts = multiline ? toolbarApi.inlineSelectionParts(toolbarParsed.source, selection.from, selection.to, toolbarParsed.tokens) : [];
|
|
1323
|
+
toolbarState = view.state;
|
|
1324
|
+
toolbarContext = {
|
|
1325
|
+
...toolbarParsed,
|
|
1326
|
+
selection,
|
|
1327
|
+
hasInlineText: !multiline || parts.length > 0,
|
|
1328
|
+
// Probe public state commands without dispatching their transactions.
|
|
1329
|
+
// Cache per EditorState so rendering button states never changes history.
|
|
1330
|
+
history: Object.fromEntries(Object.entries(historyCommands).map(([command, run]) => [
|
|
1331
|
+
command, Boolean(run?.({ state: view.state, dispatch() {} }))
|
|
1332
|
+
])),
|
|
1333
|
+
cell: tableCellContextAt(toolbarParsed.source, selection.head),
|
|
1334
|
+
label: options.ariaLabel || "본문 편집",
|
|
1335
|
+
marks: Object.entries(toolbarApi?.marks || {})
|
|
1336
|
+
.filter(([name, marker]) => multiline
|
|
1337
|
+
? parts.length > 0 && parts.every(part => toolbarApi.partMark(part, name, toolbarParsed.tokens))
|
|
1338
|
+
: toolbarParsed.tokens.some(token => (token.marker === marker || (name === "italic" && token.marker === "_")) && selection.from >= token.contentFrom && selection.to <= token.contentTo))
|
|
1339
|
+
.map(([name]) => name)
|
|
1340
|
+
};
|
|
1341
|
+
return toolbarContext;
|
|
1342
|
+
};
|
|
1343
|
+
const formatDisabledReason = command => {
|
|
1344
|
+
const context = formatContext();
|
|
1345
|
+
if (!context) return "본문을 먼저 선택하세요";
|
|
1346
|
+
if (view.composing || view.compositionStarted) return "한글 입력을 마친 뒤 사용하세요";
|
|
1347
|
+
if (view.state.selection.ranges.length !== 1) return "하나의 영역을 선택하세요";
|
|
1348
|
+
if (command === "undo" || command === "redo") return context.history[command]
|
|
1349
|
+
? "" : command === "undo" ? "실행 취소할 변경이 없습니다" : "다시 실행할 변경이 없습니다";
|
|
1350
|
+
const { source, selection, tokens, blocks, cell } = context;
|
|
1351
|
+
if (toolbarApi.marks[command] && selection.empty) return "꾸밀 텍스트를 먼저 선택하세요";
|
|
1352
|
+
if (toolbarApi.marks[command] && !context.hasInlineText) return "꾸밀 텍스트를 먼저 선택하세요";
|
|
1353
|
+
if (command === "table-delete") return tableOperation(source, selection, command) ? "" : "삭제할 표 안을 먼저 선택하세요";
|
|
1354
|
+
if (command === "table") return tableOperation(source, selection, command) ? "" : "한 표 안이나 일반 본문을 선택하세요";
|
|
1355
|
+
if (tokens.some(token => token.type === "code" && selection.from >= token.contentFrom && selection.to <= token.contentTo)) return "코드 밖에서 사용하세요";
|
|
1356
|
+
const touches = block => selection.from <= block.to && selection.to >= block.from;
|
|
1357
|
+
if (blocks.some(block => ["image", "code"].includes(block.type) && touches(block))) return "이미지·코드 블록 밖에서 사용하세요";
|
|
1358
|
+
const tableTouched = blocks.some(block => block.type === "table" && touches(block));
|
|
1359
|
+
if (tableTouched && (!toolbarApi.marks[command] || !cell || selection.from < cell.cell.from || selection.to > cell.cell.to || cell.cell.synthetic)) return "표 안에서는 한 셀의 글자 서식만 바꿀 수 있습니다";
|
|
1360
|
+
if (command === "image") return options.assets?.upload ? "" : "이 입력란에는 이미지 에셋 연결이 아직 없습니다";
|
|
1361
|
+
return "";
|
|
1362
|
+
};
|
|
1363
|
+
toolbar = toolbarApi?.register(ownerDocument, {
|
|
1364
|
+
contains: node => options.parent.contains(node),
|
|
1365
|
+
context: formatContext,
|
|
1366
|
+
disabledReason: formatDisabledReason,
|
|
1367
|
+
focus: () => view.focus(),
|
|
1368
|
+
blur: () => { if (!destroyed && !images?.hasPending()) options.onBlur?.(); },
|
|
1369
|
+
run(command, dimensions) {
|
|
1370
|
+
if (formatDisabledReason(command)) return false;
|
|
1371
|
+
if (command === "table" || command === "table-delete") {
|
|
1372
|
+
const { source, selection } = formatContext();
|
|
1373
|
+
const edit = tableOperation(source, selection, command, dimensions);
|
|
1374
|
+
if (!edit) return false;
|
|
1375
|
+
view.dispatch({ ...edit, effects: tableOperationEffect.of({ command, dimensions }), userEvent: command === "table" ? "input.table" : "delete.table", scrollIntoView: true });
|
|
1376
|
+
view.focus();
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
if (command === "image") {
|
|
1380
|
+
images?.chooseAsset();
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
if (command === "undo" || command === "redo") {
|
|
1384
|
+
const run = historyCommands[command];
|
|
1385
|
+
run?.(view);
|
|
1386
|
+
view.focus();
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
const { source, selection, tokens } = formatContext();
|
|
1390
|
+
const edit = toolbarApi.change(source, selection.anchor, selection.head, command, tokens);
|
|
1391
|
+
if (!edit) return false;
|
|
1392
|
+
view.dispatch({ ...edit, userEvent: "input.format" });
|
|
1393
|
+
view.focus();
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
const onToolbarKey = event => {
|
|
1398
|
+
if (event.altKey && event.key === "F10") { event.preventDefault(); toolbar?.focus(); }
|
|
1399
|
+
};
|
|
1400
|
+
const onComposition = () => { toolbar?.refresh(); };
|
|
1401
|
+
const onCompositionEnd = () => { setTimeout(onComposition, 0); };
|
|
1402
|
+
options.parent.addEventListener("keydown", onToolbarKey);
|
|
1403
|
+
options.parent.addEventListener("compositionstart", onComposition);
|
|
1404
|
+
options.parent.addEventListener("compositionend", onCompositionEnd);
|
|
1150
1405
|
options.parent.toggleAttribute("data-markdown-readonly", view.state.readOnly);
|
|
1151
1406
|
return Object.freeze({
|
|
1152
1407
|
setEditable(editable) {
|
|
@@ -1198,14 +1453,20 @@
|
|
|
1198
1453
|
destroy() {
|
|
1199
1454
|
if (destroyed) return;
|
|
1200
1455
|
destroyed = true;
|
|
1456
|
+
toolbar?.destroy();
|
|
1457
|
+
options.parent.removeEventListener("keydown",onToolbarKey);
|
|
1458
|
+
options.parent.removeEventListener("compositionstart",onComposition);
|
|
1459
|
+
options.parent.removeEventListener("compositionend",onCompositionEnd);
|
|
1201
1460
|
options.parent.removeAttribute("data-markdown-readonly");
|
|
1202
1461
|
images?.destroy();
|
|
1203
1462
|
view.destroy();
|
|
1463
|
+
counter.remove();
|
|
1204
1464
|
}
|
|
1205
1465
|
});
|
|
1206
1466
|
}
|
|
1207
1467
|
|
|
1208
1468
|
return Object.freeze({
|
|
1469
|
+
characterCount,
|
|
1209
1470
|
inlineTokens,
|
|
1210
1471
|
tableEscapedPipeBackslashes,
|
|
1211
1472
|
tableCellEdgeWhitespaceRanges,
|
|
@@ -1218,6 +1479,7 @@
|
|
|
1218
1479
|
syntheticTableCellMaterialization,
|
|
1219
1480
|
safeTableCellInput,
|
|
1220
1481
|
safeTableCellReplacement,
|
|
1482
|
+
tableOperation,
|
|
1221
1483
|
previewModel,
|
|
1222
1484
|
mount
|
|
1223
1485
|
});
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory(typeof module === "object" && module.exports
|
|
3
|
+
? require("./markdown-document-model.js") : root?.WriterMarkdownDocumentModel);
|
|
4
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
5
|
+
if (root) root.WriterMarkdownToolbar = api;
|
|
6
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function (documentModel) {
|
|
7
|
+
"use strict";
|
|
8
|
+
const marks = Object.freeze({
|
|
9
|
+
bold: "**",
|
|
10
|
+
italic: "*",
|
|
11
|
+
underline: "<u>",
|
|
12
|
+
strike: "~~",
|
|
13
|
+
highlight: "==",
|
|
14
|
+
brand: "++",
|
|
15
|
+
});
|
|
16
|
+
const tools = [
|
|
17
|
+
["undo", "실행 취소"],
|
|
18
|
+
["redo", "다시 실행"],
|
|
19
|
+
["bold", "굵게"],
|
|
20
|
+
["italic", "기울임"],
|
|
21
|
+
["underline", "밑줄"],
|
|
22
|
+
["strike", "취소선"],
|
|
23
|
+
["highlight", "형광펜"],
|
|
24
|
+
["brand", "색글씨"],
|
|
25
|
+
["paragraph", "일반 본문"],
|
|
26
|
+
["h1", "제목 1"],
|
|
27
|
+
["h2", "제목 2"],
|
|
28
|
+
["h3", "제목 3"],
|
|
29
|
+
["bullet", "글머리 목록"],
|
|
30
|
+
["number", "숫자 목록"],
|
|
31
|
+
["task", "체크박스 목록"],
|
|
32
|
+
["quote", "인용"],
|
|
33
|
+
["image", "이미지 삽입"],
|
|
34
|
+
["divider", "구분선"],
|
|
35
|
+
["table", "표 만들기"],
|
|
36
|
+
["table-delete", "현재 표 삭제"],
|
|
37
|
+
];
|
|
38
|
+
// Local vector icons share one stroke style and need no font or network asset.
|
|
39
|
+
const iconPaths = Object.freeze({
|
|
40
|
+
bold: "M6 4h7a4 4 0 0 1 0 8H6V4m0 8h8a4 4 0 0 1 0 8H6v-8",
|
|
41
|
+
italic: "M10 4h10M4 20h10M15 4 9 20",
|
|
42
|
+
underline: "M6 3v8a6 6 0 0 0 12 0V3M4 21h16",
|
|
43
|
+
strike: "M18 6c-1-2-3-3-6-3-4 0-6 2-6 5 0 2 2 3 6 4M6 18c1 2 3 3 6 3 4 0 6-2 6-5 0-1-1-2-2-3M3 12h18",
|
|
44
|
+
highlight: "m10 10 8-8 4 4-8 8-4-4ZM10 10l-3 3 4 4 3-3M7 13l-3 5h5l2-1",
|
|
45
|
+
brand: "m4 14 9-11a2 2 0 0 1 3 0l2 2a2 2 0 0 1 0 3L7 17l-3-3ZM4 14l-2 6 5-3M11 5l5 5M19 13c-1 2-3 4-3 5.5a3 3 0 0 0 6 0C22 17 20 15 19 13Z",
|
|
46
|
+
image: "M13 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-8M3 16l5-5 8 10M12 17l3-3 6 6M18 2v6M15 5h6M8 7h.01",
|
|
47
|
+
paragraph: "M4 4h16M12 4v16",
|
|
48
|
+
h1: "M3 5v14M12 5v14M3 12h9M16 12l3-2v9M16 19h6",
|
|
49
|
+
h2: "M3 5v14M12 5v14M3 12h9M16 12a3 3 0 0 1 6 0c0 2-6 4-6 7h6",
|
|
50
|
+
h3: "M3 5v14M12 5v14M3 12h9M16 10h6l-4 4c5-1 5 6 1 6-2 0-3-1-3-2",
|
|
51
|
+
bullet: "M9 5h12M9 12h12M9 19h12M3 5h1M3 12h1M3 19h1",
|
|
52
|
+
task: "M3 3h18v18H3V3m4 9 3 3 7-7",
|
|
53
|
+
number: "M10 5h11M10 12h11M10 19h11M3 3h1v4M2 7h4M2 11c0-2 4-2 4 0 0 1-4 2-4 4h4M2 18h4l-2 2c3-1 3 3 0 3H2",
|
|
54
|
+
quote: "M3 5h7v7H3V5m7 7c0 4-2 6-5 7M14 5h7v7h-7V5m7 7c0 4-2 6-5 7",
|
|
55
|
+
divider: "M3 12h18M8 5h8M8 19h8",
|
|
56
|
+
table: "M12 3H3v18h18v-9M3 9h9M3 15h18M9 3v18M18 2v8M14 6h8",
|
|
57
|
+
"table-delete": "M12 3H3v18h18v-9M3 9h9M3 15h18M9 3v18m6-16 7 7m0-7-7 7",
|
|
58
|
+
undo: "M9 4 3 10l6 6M3 10h11a5 5 0 0 1 0 10h-3",
|
|
59
|
+
redo: "m15 4 6 6-6 6M21 10H10a5 5 0 0 0 0 10h3",
|
|
60
|
+
});
|
|
61
|
+
function createIcon(doc, command) {
|
|
62
|
+
const ns = "http://www.w3.org/2000/svg";
|
|
63
|
+
const svg = doc.createElementNS(ns, "svg");
|
|
64
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
65
|
+
svg.setAttribute("aria-hidden", "true");
|
|
66
|
+
svg.setAttribute("focusable", "false");
|
|
67
|
+
if (command === "highlight") {
|
|
68
|
+
const swatch = doc.createElementNS(ns, "rect");
|
|
69
|
+
for (const [key, value] of Object.entries({ x: "2", y: "20", width: "20", height: "4", rx: "1", class: "writer-format-highlight-swatch" })) {
|
|
70
|
+
swatch.setAttribute(key, value);
|
|
71
|
+
}
|
|
72
|
+
svg.append(swatch);
|
|
73
|
+
}
|
|
74
|
+
const path = doc.createElementNS(ns, "path");
|
|
75
|
+
path.setAttribute("d", iconPaths[command]);
|
|
76
|
+
svg.append(path);
|
|
77
|
+
return svg;
|
|
78
|
+
}
|
|
79
|
+
// Pure source edits. The editor remains the sole selection/history/save owner.
|
|
80
|
+
function inlineSelectionParts(source, from, to, tokens = []) {
|
|
81
|
+
const parsed = documentModel.parseDocument(source, { preserveBlankLines: true });
|
|
82
|
+
const excluded = new Set(parsed.blocks.flatMap(block => block.type === "table"
|
|
83
|
+
? block.lineIndices : ["horizontalRule", "image"].includes(block.type) ? [block.lineIndex] : []));
|
|
84
|
+
return parsed.lines.flatMap(line => {
|
|
85
|
+
if (excluded.has(line.index)) return [];
|
|
86
|
+
const prefix = Math.max(0, line.headingMarkerTo, line.quoteMarkerTo, line.taskMarkerTo, line.listPrefixTo);
|
|
87
|
+
let parts = [{from:Math.max(from, line.from + prefix), to:Math.min(to, line.to)}];
|
|
88
|
+
for (const token of tokens.filter(token => token.type === "code")) {
|
|
89
|
+
parts = parts.flatMap(part => token.to <= part.from || token.from >= part.to ? [part] : [
|
|
90
|
+
{from:part.from, to:Math.min(part.to, token.from)},
|
|
91
|
+
{from:Math.max(part.from, token.to), to:part.to}
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
return parts.flatMap(part => {
|
|
95
|
+
if (part.to <= part.from) return [];
|
|
96
|
+
const text = source.slice(part.from, part.to);
|
|
97
|
+
const start = part.from + text.length - text.trimStart().length;
|
|
98
|
+
const end = part.to - (text.length - text.trimEnd().length);
|
|
99
|
+
return end > start ? [{from:start, to:end}] : [];
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function partMark(part, command, tokens) {
|
|
104
|
+
return tokens.find(token => (token.marker === marks[command] || (command === "italic" && token.marker === "_"))
|
|
105
|
+
&& part.from >= token.from && part.to <= token.to
|
|
106
|
+
&& part.to > token.contentFrom && part.from < token.contentTo);
|
|
107
|
+
}
|
|
108
|
+
function multilineChange(source, anchor, head, command, tokens) {
|
|
109
|
+
const from = Math.min(anchor, head), to = Math.max(anchor, head);
|
|
110
|
+
const parts = inlineSelectionParts(source, from, to, tokens);
|
|
111
|
+
if (!parts.length) return null;
|
|
112
|
+
const marked = parts.map(part => partMark(part, command, tokens));
|
|
113
|
+
const remove = marked.every(Boolean);
|
|
114
|
+
const edits = [];
|
|
115
|
+
parts.forEach((part, index) => {
|
|
116
|
+
const token = marked[index];
|
|
117
|
+
if (remove) {
|
|
118
|
+
edits.push({from:token.from, to:token.contentFrom, insert:""}, {from:token.contentTo, to:token.to, insert:""});
|
|
119
|
+
} else if (!token) {
|
|
120
|
+
edits.push({from:part.from, to:part.from, insert:marks[command]},
|
|
121
|
+
{from:part.to, to:part.to, insert:command === "underline" ? "</u>" : marks[command]});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const changes = [...new Map(edits.map(edit => [`${edit.from}:${edit.to}`, edit])).values()].sort((a,b) => a.from - b.from);
|
|
125
|
+
const mapPosition = (position, side) => {
|
|
126
|
+
let delta = 0;
|
|
127
|
+
for (const edit of changes) {
|
|
128
|
+
if (position < edit.from || (position === edit.from && side < 0)) break;
|
|
129
|
+
if (position < edit.to) return edit.from + delta;
|
|
130
|
+
delta += edit.insert.length - (edit.to - edit.from);
|
|
131
|
+
}
|
|
132
|
+
return position + delta;
|
|
133
|
+
};
|
|
134
|
+
const start = mapPosition(from, 1), end = mapPosition(to, -1);
|
|
135
|
+
return {changes, selection:anchor <= head ? {anchor:start,head:end} : {anchor:end,head:start}};
|
|
136
|
+
}
|
|
137
|
+
function change(source, anchor, head, command, tokens = []) {
|
|
138
|
+
const from = Math.min(anchor, head),
|
|
139
|
+
to = Math.max(anchor, head);
|
|
140
|
+
const selected = source.slice(from, to),
|
|
141
|
+
marker = marks[command];
|
|
142
|
+
if (marker) {
|
|
143
|
+
const closeMarker = command === "underline" ? "</u>" : marker;
|
|
144
|
+
if (selected.includes("\n")) return multilineChange(source, anchor, head, command, tokens);
|
|
145
|
+
const token = tokens.find(
|
|
146
|
+
(t) =>
|
|
147
|
+
(t.marker === marker || (command === "italic" && t.marker === "_")) && from >= t.contentFrom && to <= t.contentTo,
|
|
148
|
+
);
|
|
149
|
+
if (token) {
|
|
150
|
+
return {
|
|
151
|
+
changes: [
|
|
152
|
+
{ from: token.from, to: token.contentFrom },
|
|
153
|
+
{ from: token.contentTo, to: token.to },
|
|
154
|
+
],
|
|
155
|
+
selection: {
|
|
156
|
+
anchor: anchor - (token.contentFrom - token.from),
|
|
157
|
+
head: head - (token.contentFrom - token.from),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
selected.length >= marker.length + closeMarker.length &&
|
|
163
|
+
selected.startsWith(marker) &&
|
|
164
|
+
selected.endsWith(closeMarker)
|
|
165
|
+
) {
|
|
166
|
+
const text = selected.slice(marker.length, -closeMarker.length);
|
|
167
|
+
return {
|
|
168
|
+
changes: { from, to, insert: text },
|
|
169
|
+
selection: { anchor: from, head: from + text.length },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
source.slice(Math.max(0, from - marker.length), from) === marker &&
|
|
174
|
+
source.slice(to, to + closeMarker.length) === closeMarker
|
|
175
|
+
) {
|
|
176
|
+
return {
|
|
177
|
+
changes: [
|
|
178
|
+
{ from: from - marker.length, to: from },
|
|
179
|
+
{ from: to, to: to + closeMarker.length },
|
|
180
|
+
],
|
|
181
|
+
selection: {
|
|
182
|
+
anchor: anchor - marker.length,
|
|
183
|
+
head: head - marker.length,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
changes: { from, to, insert: marker + selected + closeMarker },
|
|
189
|
+
selection: {
|
|
190
|
+
anchor: anchor + marker.length,
|
|
191
|
+
head: head + marker.length,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (command === "divider" || command === "table") {
|
|
196
|
+
const text =
|
|
197
|
+
command === "divider"
|
|
198
|
+
? "---"
|
|
199
|
+
: "| 제목 | 내용 |\n| --- | --- |\n| | |";
|
|
200
|
+
const prefix =
|
|
201
|
+
from && !source.slice(0, from).endsWith("\n\n")
|
|
202
|
+
? source[from - 1] === "\n"
|
|
203
|
+
? "\n"
|
|
204
|
+
: "\n\n"
|
|
205
|
+
: "";
|
|
206
|
+
const suffix = source.slice(to).startsWith("\n\n")
|
|
207
|
+
? ""
|
|
208
|
+
: source[to] === "\n"
|
|
209
|
+
? "\n"
|
|
210
|
+
: "\n\n";
|
|
211
|
+
const position =
|
|
212
|
+
from +
|
|
213
|
+
prefix.length +
|
|
214
|
+
(command === "table" ? 2 : text.length + suffix.length);
|
|
215
|
+
return {
|
|
216
|
+
changes: { from, to, insert: prefix + text + suffix },
|
|
217
|
+
selection: { anchor: position },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
!["paragraph", "h1", "h2", "h3", "bullet", "number", "task", "quote"].includes(
|
|
222
|
+
command,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
return null;
|
|
226
|
+
const start = from === 0 ? 0 : source.lastIndexOf("\n", from - 1) + 1;
|
|
227
|
+
const endPosition = to > from && source[to - 1] === "\n" ? to - 1 : to;
|
|
228
|
+
const nextBreak = source.indexOf("\n", endPosition),
|
|
229
|
+
end = nextBreak < 0 ? source.length : nextBreak;
|
|
230
|
+
const lines = source.slice(start, end).split("\n");
|
|
231
|
+
const pattern = /^(\s*)(?:[-*] \[[ xX]\](?:[ \t]+|$)|#{1,6} |[-+*] |\d+[.)] |> )/;
|
|
232
|
+
const prefixFor = (index) =>
|
|
233
|
+
command === "paragraph"
|
|
234
|
+
? ""
|
|
235
|
+
: command[0] === "h"
|
|
236
|
+
? "#".repeat(Number(command[1])) + " "
|
|
237
|
+
: command === "bullet"
|
|
238
|
+
? "- "
|
|
239
|
+
: command === "task"
|
|
240
|
+
? "- [ ] "
|
|
241
|
+
: command === "number"
|
|
242
|
+
? `${index + 1}. `
|
|
243
|
+
: "> ";
|
|
244
|
+
const same =
|
|
245
|
+
command !== "paragraph" &&
|
|
246
|
+
lines.every((line) => {
|
|
247
|
+
const prefix = line.match(pattern)?.[0]?.trim() || "";
|
|
248
|
+
return command === "task" ? /^[-*] \[[ xX]\]$/.test(prefix) : command === "number"
|
|
249
|
+
? /^\d+[.)]$/.test(prefix)
|
|
250
|
+
: prefix === prefixFor(0).trim();
|
|
251
|
+
});
|
|
252
|
+
let offset = start;
|
|
253
|
+
const changes = lines.map((line, index) => {
|
|
254
|
+
const match = line.match(pattern),
|
|
255
|
+
indentation = match?.[1] || line.match(/^\s*/)[0];
|
|
256
|
+
const removed = match ? match[0].length : indentation.length;
|
|
257
|
+
const result = {
|
|
258
|
+
from: offset,
|
|
259
|
+
to: offset + removed,
|
|
260
|
+
insert: indentation + (same ? "" : prefixFor(index)),
|
|
261
|
+
};
|
|
262
|
+
offset += line.length + 1;
|
|
263
|
+
return result;
|
|
264
|
+
});
|
|
265
|
+
const map = (position) => {
|
|
266
|
+
let delta = 0;
|
|
267
|
+
for (const edit of changes) {
|
|
268
|
+
if (position < edit.from) break;
|
|
269
|
+
if (position <= edit.to) return edit.from + delta + edit.insert.length;
|
|
270
|
+
delta += edit.insert.length - (edit.to - edit.from);
|
|
271
|
+
}
|
|
272
|
+
return position + delta;
|
|
273
|
+
};
|
|
274
|
+
return { changes, selection: { anchor: map(anchor), head: map(head) } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const documents = new WeakMap();
|
|
278
|
+
function openTableDialog(doc, manager, command) {
|
|
279
|
+
if (manager.dialog) return;
|
|
280
|
+
const current = manager.active;
|
|
281
|
+
const original = current?.context();
|
|
282
|
+
if (!original || current.disabledReason(command)) return;
|
|
283
|
+
const creating = command === "table";
|
|
284
|
+
const dialog = doc.createElement("dialog");
|
|
285
|
+
dialog.className = "writer-format-dialog";
|
|
286
|
+
dialog.setAttribute("aria-label", creating ? "표 만들기" : "현재 표 삭제");
|
|
287
|
+
const heading = doc.createElement("h3");
|
|
288
|
+
heading.textContent = creating ? "표 만들기" : "현재 표를 삭제할까요?";
|
|
289
|
+
const description = doc.createElement("p");
|
|
290
|
+
description.textContent = creating ? "제목 행은 따로 추가됩니다. 표 안에서는 현재 표 바로 뒤에 만듭니다." : "이 표의 모든 셀 내용이 삭제됩니다. 삭제 후 실행 취소로 복원할 수 있습니다.";
|
|
291
|
+
const form = doc.createElement("form");
|
|
292
|
+
const inputs = {};
|
|
293
|
+
if (creating) {
|
|
294
|
+
for (const [name, label, value, max] of [["columns", "열 수", 2, 12], ["rows", "내용 행 수", 3, 30]]) {
|
|
295
|
+
const field = doc.createElement("label");
|
|
296
|
+
field.textContent = label;
|
|
297
|
+
const input = doc.createElement("input");
|
|
298
|
+
Object.assign(input, { type: "number", min: "1", max: String(max), step: "1", value: String(value), required: true });
|
|
299
|
+
input.setAttribute("aria-label", label);
|
|
300
|
+
field.append(input); form.append(field); inputs[name] = input;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const actions = doc.createElement("div");
|
|
304
|
+
actions.className = "writer-format-dialog-actions";
|
|
305
|
+
const cancel = doc.createElement("button");
|
|
306
|
+
cancel.type = "button"; cancel.textContent = "취소";
|
|
307
|
+
const submit = doc.createElement("button");
|
|
308
|
+
submit.type = "submit"; submit.textContent = creating ? "표 만들기" : "표 삭제";
|
|
309
|
+
submit.className = creating ? "primary" : "danger";
|
|
310
|
+
actions.append(cancel, submit); form.append(actions); dialog.append(heading, description, form);
|
|
311
|
+
let settled = false;
|
|
312
|
+
const finish = apply => {
|
|
313
|
+
if (settled) return;
|
|
314
|
+
settled = true;
|
|
315
|
+
const context = current.context();
|
|
316
|
+
const unchanged = context && context.source === original.source && context.selection.anchor === original.selection.anchor && context.selection.head === original.selection.head;
|
|
317
|
+
dialog.close(); dialog.remove(); manager.dialog = null;
|
|
318
|
+
if (context) current.focus();
|
|
319
|
+
if (apply && unchanged) current.run(command, creating ? { columns: Number(inputs.columns.value), rows: Number(inputs.rows.value) } : undefined);
|
|
320
|
+
manager.update();
|
|
321
|
+
};
|
|
322
|
+
cancel.addEventListener("click", () => finish(false));
|
|
323
|
+
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); });
|
|
324
|
+
form.addEventListener("submit", event => { event.preventDefault(); if (form.reportValidity()) finish(true); });
|
|
325
|
+
manager.dialog = dialog;
|
|
326
|
+
manager.bar.append(dialog);
|
|
327
|
+
dialog.showModal();
|
|
328
|
+
(creating ? inputs.columns : cancel).focus();
|
|
329
|
+
}
|
|
330
|
+
function viewportInset(frameHeight, visibleBottom, frameTop = 0, scale = 1) {
|
|
331
|
+
return Math.max(0, frameHeight - Math.max(0, (visibleBottom - frameTop) / scale));
|
|
332
|
+
}
|
|
333
|
+
function register(doc, target) {
|
|
334
|
+
let manager = documents.get(doc);
|
|
335
|
+
if (!manager) {
|
|
336
|
+
const win = doc.defaultView;
|
|
337
|
+
const bar = doc.createElement("div");
|
|
338
|
+
bar.className = "writer-format-toolbar";
|
|
339
|
+
bar.dataset.editing = "false";
|
|
340
|
+
bar.inert = true;
|
|
341
|
+
bar.setAttribute("aria-hidden", "true");
|
|
342
|
+
bar.setAttribute("role", "toolbar");
|
|
343
|
+
bar.setAttribute("aria-label", "텍스트 편집 도구");
|
|
344
|
+
const strip = doc.createElement("div");
|
|
345
|
+
strip.className = "writer-format-tools";
|
|
346
|
+
const buttons = new Map();
|
|
347
|
+
manager = { bar, targets: new Set(), active: null };
|
|
348
|
+
for (const [command, label] of tools) {
|
|
349
|
+
const button = doc.createElement("button");
|
|
350
|
+
button.type = "button";
|
|
351
|
+
button.setAttribute("aria-label", label);
|
|
352
|
+
button.append(createIcon(doc, command));
|
|
353
|
+
button.dataset.formatCommand = command;
|
|
354
|
+
if (["bold", "paragraph", "bullet", "image"].includes(command)) {
|
|
355
|
+
const separator = doc.createElement("span");
|
|
356
|
+
separator.className = "writer-format-separator";
|
|
357
|
+
separator.setAttribute("aria-hidden", "true");
|
|
358
|
+
strip.append(separator);
|
|
359
|
+
}
|
|
360
|
+
button.addEventListener("click", () => {
|
|
361
|
+
const current = manager.active;
|
|
362
|
+
if (!current || button.disabled) return;
|
|
363
|
+
if (command === "table" || command === "table-delete") openTableDialog(doc, manager, command);
|
|
364
|
+
else current.run(command);
|
|
365
|
+
manager.update();
|
|
366
|
+
});
|
|
367
|
+
buttons.set(command, button);
|
|
368
|
+
strip.append(button);
|
|
369
|
+
}
|
|
370
|
+
bar.append(strip);
|
|
371
|
+
doc.body.append(bar);
|
|
372
|
+
doc.body.classList.add("writer-has-format-toolbar");
|
|
373
|
+
// Keep pointer/touch actions from moving focus out of the editor.
|
|
374
|
+
bar.addEventListener("pointerdown", (event) => {
|
|
375
|
+
if (!event.target.closest("dialog") && event.target.closest("button") && event.button === 0)
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
});
|
|
378
|
+
bar.addEventListener("keydown", (event) => {
|
|
379
|
+
if (event.target.closest("dialog")) return;
|
|
380
|
+
if (event.key === "Escape") {
|
|
381
|
+
event.preventDefault();
|
|
382
|
+
manager.active?.focus();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
manager.focus = () =>
|
|
386
|
+
[...buttons.values()].find((button) => !button.disabled)?.focus();
|
|
387
|
+
manager.update = () => {
|
|
388
|
+
const current = manager.active;
|
|
389
|
+
const context = current?.context();
|
|
390
|
+
const editing = String(Boolean(context));
|
|
391
|
+
if (bar.dataset.editing !== editing) {
|
|
392
|
+
bar.dataset.editing = editing;
|
|
393
|
+
bar.inert = !context;
|
|
394
|
+
bar.setAttribute("aria-hidden", String(!context));
|
|
395
|
+
manager.position?.();
|
|
396
|
+
}
|
|
397
|
+
for (const [command, button] of buttons) {
|
|
398
|
+
const reason = context
|
|
399
|
+
? current.disabledReason(command)
|
|
400
|
+
: "본문을 먼저 선택하세요";
|
|
401
|
+
button.disabled = Boolean(reason);
|
|
402
|
+
button.title = reason || button.getAttribute("aria-label");
|
|
403
|
+
if (marks[command])
|
|
404
|
+
button.setAttribute(
|
|
405
|
+
"aria-pressed",
|
|
406
|
+
String(Boolean(context?.marks?.includes(command))),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const onFocus = (event) => {
|
|
411
|
+
if (bar.contains(event.target)) return;
|
|
412
|
+
const current = [...manager.targets].find(
|
|
413
|
+
(item) => item.contains(event.target) && item.context(),
|
|
414
|
+
);
|
|
415
|
+
if (manager.active && manager.active !== current)
|
|
416
|
+
manager.active.blur?.();
|
|
417
|
+
manager.active = current || null;
|
|
418
|
+
manager.update();
|
|
419
|
+
};
|
|
420
|
+
const onBlur = () =>
|
|
421
|
+
setTimeout(() => {
|
|
422
|
+
if (bar.contains(doc.activeElement)) return;
|
|
423
|
+
if (manager.active && !manager.active.contains(doc.activeElement)) {
|
|
424
|
+
manager.active.blur?.();
|
|
425
|
+
manager.active = null;
|
|
426
|
+
manager.update();
|
|
427
|
+
}
|
|
428
|
+
}, 0);
|
|
429
|
+
// Convert the parent's visible bottom into this iframe's coordinates.
|
|
430
|
+
// A keyboard inset alone is wrong when 100vh exceeds the visible viewport.
|
|
431
|
+
let viewportWindow = win;
|
|
432
|
+
let frame = null;
|
|
433
|
+
try {
|
|
434
|
+
if (
|
|
435
|
+
win.parent !== win &&
|
|
436
|
+
win.parent.location.origin === win.location.origin
|
|
437
|
+
) {
|
|
438
|
+
viewportWindow = win.parent;
|
|
439
|
+
frame = win.frameElement;
|
|
440
|
+
}
|
|
441
|
+
} catch (_) {}
|
|
442
|
+
const viewport = viewportWindow.visualViewport;
|
|
443
|
+
const position = () => {
|
|
444
|
+
const visibleBottom = viewport ? viewport.height + viewport.offsetTop : viewportWindow.innerHeight;
|
|
445
|
+
const rect = frame?.getBoundingClientRect();
|
|
446
|
+
const scale = rect && frame.offsetHeight ? rect.height / frame.offsetHeight : 1;
|
|
447
|
+
const top = rect ? rect.top + frame.clientTop * scale : 0;
|
|
448
|
+
const inset = viewportInset(win.innerHeight, visibleBottom, top, scale || 1);
|
|
449
|
+
bar.style.bottom = `${inset}px`;
|
|
450
|
+
doc.body.style.setProperty("--writer-visible-bottom-inset", `${inset}px`);
|
|
451
|
+
doc.body.style.setProperty("--writer-toolbar-clearance", `${inset + bar.offsetHeight}px`);
|
|
452
|
+
};
|
|
453
|
+
manager.position = position;
|
|
454
|
+
doc.addEventListener("focusin", onFocus);
|
|
455
|
+
doc.addEventListener("focusout", onBlur);
|
|
456
|
+
viewport?.addEventListener("resize", position);
|
|
457
|
+
viewport?.addEventListener("scroll", position);
|
|
458
|
+
win.addEventListener("resize", position);
|
|
459
|
+
viewportWindow.addEventListener("scroll", position, true);
|
|
460
|
+
if (viewportWindow !== win) viewportWindow.addEventListener("resize", position);
|
|
461
|
+
const sizeObserver = new win.ResizeObserver(position);
|
|
462
|
+
sizeObserver.observe(bar, { box: "border-box" });
|
|
463
|
+
position();
|
|
464
|
+
manager.dispose = () => {
|
|
465
|
+
sizeObserver.disconnect();
|
|
466
|
+
doc.removeEventListener("focusin", onFocus);
|
|
467
|
+
doc.removeEventListener("focusout", onBlur);
|
|
468
|
+
viewport?.removeEventListener("resize", position);
|
|
469
|
+
viewport?.removeEventListener("scroll", position);
|
|
470
|
+
win.removeEventListener("resize", position);
|
|
471
|
+
viewportWindow.removeEventListener("scroll", position, true);
|
|
472
|
+
if (viewportWindow !== win) viewportWindow.removeEventListener("resize", position);
|
|
473
|
+
bar.remove();
|
|
474
|
+
doc.body.style.removeProperty("--writer-visible-bottom-inset");
|
|
475
|
+
doc.body.style.removeProperty("--writer-toolbar-clearance");
|
|
476
|
+
doc.body.classList.remove("writer-has-format-toolbar");
|
|
477
|
+
documents.delete(doc);
|
|
478
|
+
};
|
|
479
|
+
documents.set(doc, manager);
|
|
480
|
+
manager.update();
|
|
481
|
+
}
|
|
482
|
+
manager.targets.add(target);
|
|
483
|
+
return {
|
|
484
|
+
ownsFocus: () =>
|
|
485
|
+
manager.active === target && manager.bar.contains(doc.activeElement),
|
|
486
|
+
focus() {
|
|
487
|
+
if (manager.active === target) manager.focus();
|
|
488
|
+
},
|
|
489
|
+
refresh() {
|
|
490
|
+
// Touch can focus a readonly editor before it becomes editable, without
|
|
491
|
+
// firing another focusin. Reconnect from the current focus on that update.
|
|
492
|
+
if (manager.active !== target && target.contains(doc.activeElement) && target.context()) {
|
|
493
|
+
manager.active?.blur?.();
|
|
494
|
+
manager.active = target;
|
|
495
|
+
}
|
|
496
|
+
if (manager.active === target) {
|
|
497
|
+
if (!target.context()) manager.active = null;
|
|
498
|
+
manager.update();
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
destroy() {
|
|
502
|
+
manager.targets.delete(target);
|
|
503
|
+
if (manager.active === target) {
|
|
504
|
+
manager.dialog?.remove();
|
|
505
|
+
manager.dialog = null;
|
|
506
|
+
manager.active = null;
|
|
507
|
+
}
|
|
508
|
+
if (!manager.targets.size) manager.dispose();
|
|
509
|
+
else manager.update();
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return Object.freeze({ change, register, marks, viewportInset, inlineSelectionParts, partMark });
|
|
514
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssobig/writer-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ssobig/writer-cli",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.3",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@supabase/supabase-js": "2.110.9"
|
|
12
12
|
},
|