@ssobig/writer-cli 0.3.2 → 0.3.4
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 +3 -3
- package/templates/mystery-v1/component-field-contracts.js +37 -0
- package/templates/mystery-v1/component-renderers.js +1 -1
- package/templates/mystery-v1/component-storage-contract.js +3 -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/templates/mystery-v1/timeline-model.js +62 -1
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
|
@@ -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
|
+
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// events는 사건이 시간을 소유하는 legacy 모양, timeLevels/entries는 항목이 시간을 소유하는 v2 모양이다.
|
|
17
17
|
function detectShape(data) {
|
|
18
18
|
if (!isObject(data)) return "invalid";
|
|
19
|
+
if (Object.hasOwn(data, "timeGroups")) return Array.isArray(data.timeGroups) && Array.isArray(data.events) && Array.isArray(data.places) && !Object.hasOwn(data, "timeLevels") && !Object.hasOwn(data, "entries") ? "events" : "invalid";
|
|
19
20
|
const events = Array.isArray(data.events);
|
|
20
21
|
const levels = Array.isArray(data.timeLevels);
|
|
21
22
|
const entries = Array.isArray(data.entries);
|
|
@@ -224,12 +225,72 @@
|
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
// 활성 런타임이 중립 이름으로 소비하는 정규화 진입점: 어떤 등록 모양이든 v2 data를 돌려준다.
|
|
228
|
+
// Explicit, reviewed migration only. Rendering and ordinary saves never call this.
|
|
229
|
+
function convertToEventTimeline(data) {
|
|
230
|
+
if (detectShape(data) !== "v2") throw new Error("시간 그룹 이관은 timeLevels/entries 원본만 지원합니다.");
|
|
231
|
+
if (data.entries.some(entry => entry.lane.kind === "unresolved")) throw new Error("미연결 캐릭터를 먼저 해결해 주세요.");
|
|
232
|
+
const places = JSON.parse(JSON.stringify(data.places || []));
|
|
233
|
+
const groups = data.timeLevels.map(level => ({ id: level.id, name: level.name, type: level.mode === "clock" ? "timeline" : "text" }));
|
|
234
|
+
const events = data.entries.map(entry => {
|
|
235
|
+
const group = groups.find(group => group.id === entry.timeLevelId);
|
|
236
|
+
if (!group) throw new Error("존재하지 않는 시간 그룹입니다.");
|
|
237
|
+
const ids = entry.placeIds || [];
|
|
238
|
+
const matched = places.find(place => place.name.trim() === text(entry.location).trim());
|
|
239
|
+
const placeId = ids.at(-1) || matched?.id || null;
|
|
240
|
+
const annotations = [];
|
|
241
|
+
const location = text(entry.location).trim();
|
|
242
|
+
if (ids.length > 1) annotations.push(`기존 이동 경로: ${entryPlaceNames(data, entry).join(" → ")}`);
|
|
243
|
+
if (location && ![places.find(place => place.id === placeId)?.name, entryPlaceNames(data, entry).join(" → ")].includes(location)) annotations.push(`기존 장소 메모: ${location}`);
|
|
244
|
+
if (!ids.length && !matched && location && !annotations.some(value => value.endsWith(location))) annotations.push(`기존 장소 메모: ${location}`);
|
|
245
|
+
const event = { id: entry.id, timeGroupId: entry.timeLevelId, description: entry.text, characterId: entry.lane.kind === "character" ? entry.lane.characterId : null, placeId };
|
|
246
|
+
if (entry.included === false) event.included = false;
|
|
247
|
+
if (group.type === "timeline") {
|
|
248
|
+
if (entry.when.kind !== "clock") throw new Error("시각이 없는 사건의 시간 범위를 먼저 지정해 주세요.");
|
|
249
|
+
const start = (entry.when.dayOffset || 0) * 1440 + entry.when.startMinute;
|
|
250
|
+
const end = entry.when.endMinute === undefined ? start + 30 : (entry.when.dayOffset || 0) * 1440 + entry.when.endMinute;
|
|
251
|
+
if (end <= start) throw new Error("시작 이후의 종료 시간을 지정해 주세요.");
|
|
252
|
+
event.timeRange = { start: minutePoint(start), end: minutePoint(end) };
|
|
253
|
+
const clockLabel = minute => `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
|
254
|
+
const canonical = clockLabel(entry.when.startMinute) + (entry.when.endMinute === undefined ? "" : `~${clockLabel(entry.when.endMinute)}`);
|
|
255
|
+
const normalized = text(entry.timeLabel).replace(/[–—−-]/g, "~").replace(/\s/g, "");
|
|
256
|
+
if (normalized && normalized !== canonical) annotations.push(`기존 시간 표현: ${entry.timeLabel}`);
|
|
257
|
+
} else if (text(entry.timeLabel).trim()) annotations.push(`기존 시간 표현: ${entry.timeLabel}`);
|
|
258
|
+
if (annotations.length) event.description += `${event.description ? "\n\n" : ""}${annotations.join("\n")}`;
|
|
259
|
+
return event;
|
|
260
|
+
});
|
|
261
|
+
return { timeGroups: groups, events, places };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pointMinutes(point) { return point.day * 1440 + point.hour * 60 + point.minute; }
|
|
265
|
+
function minutePoint(value) { return { day: Math.floor(value / 1440), hour: Math.floor(value % 1440 / 60), minute: value % 60 }; }
|
|
266
|
+
function defaultRange(start = { day: 0, hour: 9, minute: 0 }) { return { start: { ...start }, end: minutePoint(pointMinutes(start) + 30) }; }
|
|
267
|
+
function pointLabel(point) { return `${point.day + 1}일차 ${String(point.hour).padStart(2, "0")}:${String(point.minute).padStart(2, "0")}`; }
|
|
268
|
+
function rangeLabel(range) { return range ? `${pointLabel(range.start)}–${pointLabel(range.end)}` : ""; }
|
|
269
|
+
function orderedEvents(data) {
|
|
270
|
+
const groups = new Map(data.timeGroups.map((group, index) => [group.id, { ...group, index }]));
|
|
271
|
+
return data.events.map((event, index) => ({ event, index })).sort((a, b) => {
|
|
272
|
+
const ga = groups.get(a.event.timeGroupId), gb = groups.get(b.event.timeGroupId);
|
|
273
|
+
return ga.index - gb.index || (ga.type === "timeline" ? pointMinutes(a.event.timeRange.start) - pointMinutes(b.event.timeRange.start) : 0) || a.index - b.index;
|
|
274
|
+
}).map(item => item.event);
|
|
275
|
+
}
|
|
276
|
+
// Projection only: old board/output consumers never own this derived representation.
|
|
277
|
+
function eventTimelineProjection(data) {
|
|
278
|
+
return { timeLevels: data.timeGroups.map(group => ({ id: group.id, name: group.name, mode: group.type === "timeline" ? "clock" : "manual" })), places: data.places,
|
|
279
|
+
entries: orderedEvents(data).map(event => ({ id: event.id, timeLevelId: event.timeGroupId,
|
|
280
|
+
lane: event.characterId === null ? { kind: "truth" } : { kind: "character", characterId: event.characterId },
|
|
281
|
+
when: event.timeRange ? { kind: "clock", dayOffset: event.timeRange.start.day, startMinute: event.timeRange.start.hour * 60 + event.timeRange.start.minute,
|
|
282
|
+
endMinute: pointMinutes(event.timeRange.end) - event.timeRange.start.day * 1440 } : { kind: "unspecified" },
|
|
283
|
+
timeLabel: rangeLabel(event.timeRange), text: event.description, included: event.included !== false,
|
|
284
|
+
placeIds: event.placeId === null ? [] : [event.placeId], location: "" })) };
|
|
285
|
+
}
|
|
286
|
+
|
|
227
287
|
function normalizedTimelineData(data, options = {}) {
|
|
288
|
+
if (detectShape(data) === "events") return eventTimelineProjection(data);
|
|
228
289
|
const shape = detectShape(data);
|
|
229
290
|
if (shape === "v2") return data;
|
|
230
291
|
if (shape === "legacy") return convertLegacyTimeline(data, options);
|
|
231
292
|
throw new Error("타임라인 data가 등록된 모양이 아닙니다.");
|
|
232
293
|
}
|
|
233
294
|
|
|
234
|
-
return Object.freeze({ detectShape, convertLegacyTimeline, compareLegacyToV2, normalizedTimelineData, entryPlaceNames, orderedEntries, overlapGroups, deriveClusters });
|
|
295
|
+
return Object.freeze({ convertToEventTimeline, pointMinutes, minutePoint, defaultRange, pointLabel, rangeLabel, orderedEvents, eventTimelineProjection, detectShape, convertLegacyTimeline, compareLegacyToV2, normalizedTimelineData, entryPlaceNames, orderedEntries, overlapGroups, deriveClusters });
|
|
235
296
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssobig/writer-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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.4",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@supabase/supabase-js": "2.110.9"
|
|
12
12
|
},
|