@ssobig/writer-cli 0.2.2 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +19 -0
  2. package/asset-repository.js +7 -2
  3. package/config.js +2 -1
  4. package/package.json +1 -1
  5. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  6. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  7. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  8. package/templates/mystery-v1/component-asset-operations.js +19 -6
  9. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  10. package/templates/mystery-v1/component-draft-operations.js +19 -8
  11. package/templates/mystery-v1/component-field-contracts.js +127 -51
  12. package/templates/mystery-v1/component-id-policy.js +1 -1
  13. package/templates/mystery-v1/component-manager.js +21 -11
  14. package/templates/mystery-v1/component-navigation-counts.js +9 -9
  15. package/templates/mystery-v1/component-registry.js +11 -5
  16. package/templates/mystery-v1/component-renderers.js +12 -8
  17. package/templates/mystery-v1/component-storage-contract.js +28 -9
  18. package/templates/mystery-v1/markdown-document-model.js +444 -0
  19. package/templates/mystery-v1/markdown-image-editor.js +320 -0
  20. package/templates/mystery-v1/markdown-live-editor.js +1224 -0
  21. package/templates/mystery-v1/page-header.js +2 -1
  22. package/templates/mystery-v1/timeline-model.js +235 -0
  23. package/tools/writer-cli/package-lock.json +2 -2
  24. package/tools/writer-cli/package.json +1 -1
  25. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +13 -14
  26. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
  27. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
  28. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
  29. package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
  30. package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
  31. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +8 -2
  32. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
  33. package/tools/writer-cli/src/agent-service.cjs +3 -1
  34. package/tools/writer-cli/src/command-registry.cjs +24 -20
  35. package/tools/writer-cli/src/commands.cjs +106 -3
  36. package/tools/writer-cli/src/domain.cjs +296 -1
  37. package/tools/writer-cli/src/gateway.cjs +14 -0
  38. package/tools/writer-cli/src/mutations.cjs +167 -1
  39. package/tools/writer-cli/src/project-import.cjs +27 -22
@@ -0,0 +1,320 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === "object" && module.exports) module.exports = api;
4
+ if (root) root.WriterMarkdownImageEditor = api;
5
+ })(typeof globalThis !== "undefined" ? globalThis : this, function () {
6
+ "use strict";
7
+
8
+ const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
9
+ function insertion(source, position, markdown) {
10
+ const before = source.slice(0, position);
11
+ const after = source.slice(position);
12
+ const prefix = before && !before.endsWith("\n\n") ? (before.endsWith("\n") ? "\n" : "\n\n") : "";
13
+ const suffix = after.startsWith("\n\n") ? "" : after.startsWith("\n") ? "\n" : "\n\n";
14
+ return { from: position, insert: prefix + markdown + suffix };
15
+ }
16
+
17
+ function create({ cm, model, assets, parent, canInsert, onIdle }) {
18
+ const doc = parent.ownerDocument;
19
+ let view = null;
20
+ let disposed = false;
21
+ let serial = Promise.resolve();
22
+ const jobs = new Map();
23
+ const fileInputs = new Set();
24
+ const bookmarkEffect = cm.StateEffect.define();
25
+ const bookmarks = cm.StateField.define({
26
+ create: () => new Map(),
27
+ update(previous, transaction) {
28
+ const next = new Map();
29
+ previous.forEach((mark, id) => {
30
+ let removed = false;
31
+ transaction.changes.iterChangedRanges((from, to) => {
32
+ if (to > from && (mark.to > mark.from
33
+ ? from < mark.to && to > mark.from
34
+ : from <= mark.from && to > mark.from)) removed = true;
35
+ });
36
+ if (!removed) next.set(id, {
37
+ from: transaction.changes.mapPos(mark.from, 1),
38
+ to: transaction.changes.mapPos(mark.to, mark.to > mark.from ? -1 : 1)
39
+ });
40
+ });
41
+ transaction.effects.forEach(effect => {
42
+ if (effect.is(bookmarkEffect)) {
43
+ if (effect.value.mark) next.set(effect.value.id, effect.value.mark);
44
+ else next.delete(effect.value.id);
45
+ }
46
+ });
47
+ return next;
48
+ }
49
+ });
50
+
51
+ const imageBlocks = state => model.parseDocument(state.doc.toString(), { preserveBlankLines: true }).blocks.filter(block => block.type === "image");
52
+ function selectBlock(figure) {
53
+ const from = view.posAtDOM(figure);
54
+ return imageBlocks(view.state).find(block => from >= block.from && from <= block.to);
55
+ }
56
+ function button(label, action) {
57
+ const element = doc.createElement("button");
58
+ element.type = "button";
59
+ element.textContent = label;
60
+ element.addEventListener("mousedown", event => event.preventDefault());
61
+ element.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); if (!view.state.readOnly) action(); });
62
+ return element;
63
+ }
64
+
65
+ // Implements CodeMirror's WidgetType interface without changing the pinned bundle.
66
+ class ImageWidget {
67
+ constructor(block) { this.block = block; }
68
+ eq(other) { return other instanceof ImageWidget && other.block.assetId === this.block.assetId && other.block.alt === this.block.alt; }
69
+ compare(other) { return this === other || this.eq(other); }
70
+ get estimatedHeight() { return 180; }
71
+ get lineBreaks() { return 0; }
72
+ updateDOM() { return false; }
73
+ ignoreEvent() { return true; }
74
+ coordsAt() { return null; }
75
+ destroy() {}
76
+ toDOM(editor) {
77
+ const figure = doc.createElement("figure");
78
+ figure.className = "writer-md-image writer-md-image-editable";
79
+ figure.contentEditable = "false";
80
+ figure.tabIndex = 0;
81
+ figure.setAttribute("aria-label", this.block.alt || "본문 이미지");
82
+ const image = doc.createElement("img");
83
+ image.dataset.componentAsset = this.block.assetId;
84
+ image.alt = this.block.alt;
85
+ image.draggable = false;
86
+ const status = doc.createElement("span");
87
+ status.className = "writer-md-image-status";
88
+ status.textContent = "이미지를 불러오는 중입니다.";
89
+ const url = assets?.get?.(this.block.assetId);
90
+ image.hidden = !url;
91
+ if (url) image.src = url;
92
+ image.addEventListener("load", () => editor.requestMeasure());
93
+ image.addEventListener("error", () => { image.hidden = true; status.textContent = "이미지를 불러오지 못했습니다."; editor.requestMeasure(); });
94
+ figure.append(image, status);
95
+ if (assets?.upload) {
96
+ const actions = doc.createElement("span");
97
+ actions.className = "writer-md-image-actions";
98
+ actions.append(button("교체", () => {
99
+ const block = selectBlock(figure);
100
+ if (block) chooseFiles({ from: block.from, to: block.to });
101
+ }), button("본문에서 제거", () => {
102
+ const block = selectBlock(figure);
103
+ if (!block) return;
104
+ editor.dispatch({ changes: { from: block.from, to: block.to }, selection: { anchor: block.from }, userEvent: "delete" });
105
+ editor.focus();
106
+ }));
107
+ figure.append(actions);
108
+ }
109
+ figure.addEventListener("keydown", event => {
110
+ if (editor.state.readOnly) return;
111
+ if (!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) return;
112
+ const block = selectBlock(figure);
113
+ if (!block) return;
114
+ event.preventDefault();
115
+ event.stopPropagation();
116
+ if (["Backspace", "Delete"].includes(event.key)) editor.dispatch({ changes: { from: block.from, to: block.to }, selection: { anchor: block.from }, userEvent: "delete" });
117
+ else {
118
+ const backward = ["ArrowLeft", "ArrowUp"].includes(event.key);
119
+ const position = backward ? block.from : block.to;
120
+ if ((!backward && position === editor.state.doc.length) || (backward && position === 0)) {
121
+ editor.dispatch({ changes: { from: position, insert: "\n" }, selection: { anchor: backward ? 0 : position + 1 }, userEvent: "input" });
122
+ } else editor.dispatch({ selection: { anchor: backward ? Math.max(0, position - 1) : Math.min(editor.state.doc.length, position + 1) } });
123
+ }
124
+ editor.focus();
125
+ });
126
+ return figure;
127
+ }
128
+ }
129
+ const decorate = state => cm.Decoration.set(imageBlocks(state).map(block => cm.Decoration.replace({ widget: new ImageWidget(block), block: true }).range(block.from, block.to)), true);
130
+ const decorations = cm.StateField.define({
131
+ create: decorate,
132
+ update(previous, transaction) {
133
+ return transaction.docChanged ? decorate(transaction.state) : previous;
134
+ },
135
+ provide: field => [cm.EditorView.decorations.from(field), cm.EditorView.atomicRanges.from(field, ranges => () => ranges)]
136
+ });
137
+
138
+ const toolbar = doc.createElement("div");
139
+ toolbar.className = "writer-md-image-toolbar";
140
+ toolbar.setAttribute("aria-label", "본문 이미지 도구");
141
+ const feedback = doc.createElement("div");
142
+ feedback.className = "writer-md-image-feedback";
143
+ feedback.setAttribute("role", "status");
144
+ feedback.hidden = true;
145
+ function say(message) { feedback.hidden = false; feedback.replaceChildren(doc.createTextNode(message)); }
146
+ function cancel(id) {
147
+ jobs.delete(id);
148
+ if (!disposed) view.dispatch({ effects: bookmarkEffect.of({ id }) });
149
+ renderJobs();
150
+ }
151
+ function renderJobs() {
152
+ if (disposed) return;
153
+ feedback.replaceChildren();
154
+ feedback.hidden = jobs.size === 0;
155
+ if (!jobs.size) onIdle?.();
156
+ jobs.forEach((job, id) => {
157
+ const row = doc.createElement("div");
158
+ row.append(doc.createTextNode(`${job.file.name || "이미지"}: ${job.error || "업로드 중…"} `));
159
+ if (job.error) row.append(button("재시도", () => { job.error = ""; enqueue(id); }));
160
+ row.append(button("취소", () => cancel(id)));
161
+ feedback.append(row);
162
+ });
163
+ }
164
+ function addBookmark(mark) {
165
+ const id = doc.defaultView.crypto.randomUUID();
166
+ view.dispatch({ effects: bookmarkEffect.of({ id, mark }) });
167
+ return id;
168
+ }
169
+ function allowed(mark) {
170
+ if (view.state.readOnly) return false;
171
+ if (view.composing) { say("한글 입력을 마친 뒤 이미지를 넣어주세요."); return false; }
172
+ if (!canInsert(view.state.doc.toString(), mark.from, mark.to)) { say("표 바깥의 본문에 이미지를 넣어주세요."); return false; }
173
+ return true;
174
+ }
175
+ function commit(id, assetId, alt) {
176
+ let mark = view.state.field(bookmarks).get(id);
177
+ if (!mark || !allowed(mark)) return false;
178
+ if (mark.from === mark.to) {
179
+ const image = imageBlocks(view.state).find(block => mark.from >= block.from && mark.from <= block.to);
180
+ if (image) mark = { from: image.to, to: image.to };
181
+ }
182
+ const markdown = model.imageMarkdown(assetId, alt);
183
+ const change = mark.to > mark.from
184
+ ? { from: mark.from, to: mark.to, insert: markdown }
185
+ : insertion(view.state.doc.toString(), mark.from, markdown);
186
+ const followsInsertion = mark.from === mark.to && view.state.selection.main.empty && view.state.selection.main.head === mark.from;
187
+ view.dispatch({ changes: change, ...(followsInsertion ? { selection: { anchor: change.from + change.insert.length } } : {}), effects: bookmarkEffect.of({ id }), userEvent: "input.image" });
188
+ return true;
189
+ }
190
+ function enqueue(id) {
191
+ renderJobs();
192
+ serial = serial.then(async () => {
193
+ const job = jobs.get(id);
194
+ if (!job || disposed || !view.state.field(bookmarks).has(id)) { cancel(id); return; }
195
+ try {
196
+ // A replacement gets a new ID so other references and undo retain the original.
197
+ job.assetId ||= `body-image-${doc.defaultView.crypto.randomUUID()}`;
198
+ if (!job.uploaded) { await assets.upload(job.assetId, job.file); job.uploaded = true; }
199
+ if (disposed || jobs.get(id) !== job) return;
200
+ // Wait for CodeMirror to finish its IME transaction, not only the DOM event.
201
+ while (!disposed && jobs.get(id) === job && view.composing) {
202
+ await new Promise(resolve => doc.defaultView.setTimeout(resolve, 30));
203
+ }
204
+ if (disposed || jobs.get(id) !== job) return;
205
+ const inserted = commit(id, job.assetId, job.file.name || "이미지");
206
+ jobs.delete(id);
207
+ renderJobs();
208
+ if (!inserted) say("삽입 위치가 변경되어 에셋에만 저장했습니다. 에셋에서 다시 선택할 수 있습니다.");
209
+ } catch (error) {
210
+ if (disposed || jobs.get(id) !== job) return;
211
+ job.error = error?.message || "업로드하지 못했습니다.";
212
+ renderJobs();
213
+ }
214
+ });
215
+ }
216
+ function uploadFiles(files, mark = { from: view.state.selection.main.head, to: view.state.selection.main.head }) {
217
+ if (!allowed(mark)) return;
218
+ for (const file of files) {
219
+ if (!IMAGE_TYPES.includes(file.type) || file.size > 15 * 1024 * 1024) { say("JPEG·PNG·WebP·GIF 이미지를 15MB 이하로 선택해주세요."); continue; }
220
+ const id = addBookmark(mark);
221
+ jobs.set(id, { file });
222
+ enqueue(id);
223
+ if (mark.to > mark.from) break;
224
+ }
225
+ }
226
+ function chooseFiles(mark = { from: view.state.selection.main.head, to: view.state.selection.main.head }) {
227
+ if (!allowed(mark)) return;
228
+ const id = addBookmark(mark);
229
+ const input = doc.createElement("input");
230
+ input.type = "file";
231
+ input.accept = IMAGE_TYPES.join(",");
232
+ input.multiple = mark.from === mark.to;
233
+ input.hidden = true;
234
+ fileInputs.add(input);
235
+ parent.append(input);
236
+ const clean = () => { input.remove(); fileInputs.delete(input); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); if (!jobs.size) onIdle?.(); } };
237
+ input.addEventListener("cancel", clean, { once: true });
238
+ input.addEventListener("change", () => {
239
+ const current = !disposed && view.state.field(bookmarks).get(id);
240
+ if (current) uploadFiles([...input.files], current);
241
+ clean();
242
+ }, { once: true });
243
+ input.click();
244
+ }
245
+ function chooseAsset() {
246
+ const mark = { from: view.state.selection.main.head, to: view.state.selection.main.head };
247
+ if (!allowed(mark)) return;
248
+ const id = addBookmark(mark);
249
+ const dialog = doc.createElement("dialog");
250
+ dialog.className = "writer-md-image-picker";
251
+ dialog.setAttribute("aria-label", "에셋 이미지 선택");
252
+ const heading = doc.createElement("h3"); heading.textContent = "에셋에서 이미지 선택";
253
+ dialog.append(heading);
254
+ const list = doc.createElement("div"); list.className = "writer-md-image-picker-list";
255
+ const entries = (assets.list?.() || []).filter(entry => IMAGE_TYPES.includes(entry.type));
256
+ entries.forEach(entry => {
257
+ const item = button(entry.name || entry.assetId, () => {
258
+ commit(id, entry.assetId, entry.name || "이미지");
259
+ dialog.close();
260
+ });
261
+ const url = assets.get(entry.assetId);
262
+ if (url) { const image = doc.createElement("img"); image.src = url; image.alt = ""; item.prepend(image); }
263
+ list.append(item);
264
+ });
265
+ if (!entries.length) list.textContent = "등록된 이미지가 없습니다. 이미지 업로드를 이용해주세요.";
266
+ dialog.append(list, button("닫기", () => dialog.close()));
267
+ dialog.addEventListener("close", () => { dialog.remove(); if (!disposed) { view.dispatch({ effects: bookmarkEffect.of({ id }) }); view.focus(); } }, { once: true });
268
+ parent.append(dialog);
269
+ dialog.showModal();
270
+ }
271
+ const events = cm.EditorView.domEventHandlers({
272
+ paste(event) {
273
+ const files = [...(event.clipboardData?.files || [])];
274
+ if (view.state.readOnly || !assets?.upload || !files.length) return false;
275
+ event.preventDefault(); uploadFiles(files); return true;
276
+ },
277
+ dragover(event) {
278
+ if (view.state.readOnly || !assets?.upload || ![...(event.dataTransfer?.types || [])].includes("Files")) return false;
279
+ event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "copy"; return true;
280
+ },
281
+ drop(event) {
282
+ const files = [...(event.dataTransfer?.files || [])];
283
+ if (view.state.readOnly || !assets?.upload || !files.length) return false;
284
+ event.preventDefault(); event.stopPropagation();
285
+ const from = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head;
286
+ uploadFiles(files, { from, to: from }); return true;
287
+ }
288
+ });
289
+ function protectBoundary(backward) {
290
+ return editor => {
291
+ const selection = editor.state.selection.main;
292
+ if (editor.composing || !selection.empty) return false;
293
+ const block = imageBlocks(editor.state).find(item => backward
294
+ ? selection.head === item.to + 1 || selection.head === item.to
295
+ : selection.head === item.from - 1 || selection.head === item.from);
296
+ if (!block) return false;
297
+ const figure = [...editor.dom.querySelectorAll('.writer-md-image-editable')].find(element => editor.posAtDOM(element) === block.from);
298
+ figure?.focus();
299
+ return true;
300
+ };
301
+ }
302
+ return {
303
+ hasPending: () => jobs.size > 0 || fileInputs.size > 0,
304
+ extensions: [bookmarks, decorations, events, cm.keymap.of([
305
+ { key: "Backspace", run: protectBoundary(true) },
306
+ { key: "Delete", run: protectBoundary(false) }
307
+ ])],
308
+ attach(editor) {
309
+ view = editor;
310
+ if (assets?.upload) {
311
+ toolbar.append(button("이미지 업로드", () => chooseFiles()), button("에셋에서 선택", chooseAsset));
312
+ parent.prepend(toolbar);
313
+ parent.append(feedback);
314
+ }
315
+ },
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()); }
317
+ };
318
+ }
319
+ return Object.freeze({ create, insertion });
320
+ });