@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
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
|
},
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
templateId: "ssobig.timeline", defaultInstanceId: "timeline", tabLabel: "타임라인",
|
|
104
104
|
description: "사건이 일어난 시간과 장소, 캐릭터별 기억과 행동을 시간순으로 정리합니다.",
|
|
105
105
|
guide: guide("사건의 객관적 흐름과 인물별 기억을 시간축으로 맞춥니다.", "22시 로비 사건과 각 캐릭터가 기억하는 행동을 나란히 기록합니다.", "시간·장소·진실과 캐릭터별 기억을 사건 단위로 편집합니다.", "context", "캐릭터 정보를 읽어 인물별 기억을 연결하지만 독립 플레이 화면은 제공하지 않습니다.", ["ssobig.character"], "타임라인 제작 화면 예시"),
|
|
106
|
-
defaultData: { events: [] },
|
|
106
|
+
defaultData: { timeGroups: [], events: [], places: [] },
|
|
107
107
|
view: view("ssobig.view.timeline-workbench", "timeline-workbench", "타임라인 작업 화면", "ssobig.timeline", [
|
|
108
108
|
binding("primary", "ssobig.timeline", "read_write"), binding("character-context", "ssobig.character", "read", false)
|
|
109
109
|
]),
|
|
@@ -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
|
},
|
|
@@ -312,6 +312,37 @@
|
|
|
312
312
|
})
|
|
313
313
|
]);
|
|
314
314
|
|
|
315
|
+
const EVENT_TIMELINE_CONTRACT = component({
|
|
316
|
+
templateId: "ssobig.timeline",
|
|
317
|
+
root: shape({ timeGroups: field("array", { item: "group" }), events: field("array", { item: "event" }), places: field("array", { item: "place" }) }),
|
|
318
|
+
items: {
|
|
319
|
+
group: shape({ id: field("uuid"), name: field("non-empty-string"), type: field("enum", { values: ["text", "timeline"] }) }),
|
|
320
|
+
place: shape({ id: field("uuid"), name: field("non-empty-string") }),
|
|
321
|
+
event: shape({ id: field("uuid"), description: field("string"), timeGroupId: field("uuid"),
|
|
322
|
+
placeId: field("nullable-uuid"), characterId: field("nullable-stable-key")
|
|
323
|
+
}, { timeRange: field("object", { item: "range" }), included: field("boolean") }),
|
|
324
|
+
range: shape({ start: field("object", { item: "point" }), end: field("object", { item: "point" }) }),
|
|
325
|
+
point: shape({ day: field("integer", { minimum: 0, maximum: 999999 }), hour: field("integer", { minimum: 0, maximum: 23 }), minute: field("integer", { minimum: 0, maximum: 59 }) })
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
function eventTimelineError(data) {
|
|
329
|
+
const invalid = validateShape(data, EVENT_TIMELINE_CONTRACT.root, EVENT_TIMELINE_CONTRACT, "$");
|
|
330
|
+
if (invalid) return invalid;
|
|
331
|
+
const groups = new Map(data.timeGroups.map(group => [group.id, group]));
|
|
332
|
+
const places = new Set(data.places.map(place => place.id));
|
|
333
|
+
const minutes = point => point.day * 1440 + point.hour * 60 + point.minute;
|
|
334
|
+
for (const event of data.events) {
|
|
335
|
+
const group = groups.get(event.timeGroupId);
|
|
336
|
+
if (!group) return "사건의 시간 그룹이 존재하지 않습니다.";
|
|
337
|
+
if (event.placeId !== null && !places.has(event.placeId)) return "사건의 장소가 존재하지 않습니다.";
|
|
338
|
+
if (group.type === "timeline") {
|
|
339
|
+
if (!event.timeRange) return "타임라인 그룹의 사건에는 시작·종료 시간이 필수입니다.";
|
|
340
|
+
if (minutes(event.timeRange.end) <= minutes(event.timeRange.start)) return "종료는 시작 이후여야 합니다.";
|
|
341
|
+
} else if (Object.hasOwn(event, "timeRange")) return "텍스트 그룹의 사건에는 시간 범위를 저장하지 않습니다.";
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
315
346
|
const BY_ID = new Map(CONTRACTS.map(contract => [contract.templateId, contract]));
|
|
316
347
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
317
348
|
const STABLE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
@@ -352,6 +383,8 @@
|
|
|
352
383
|
case "non-empty-string": return { type: "string", minLength: 1, pattern: "\\S" };
|
|
353
384
|
case "stable-key": return { type: "string", pattern: STABLE_KEY_PATTERN.source };
|
|
354
385
|
case "uuid": return { type: "string", format: "uuid" };
|
|
386
|
+
case "nullable-uuid": return { anyOf: [{ type: "null" }, { type: "string", format: "uuid" }] };
|
|
387
|
+
case "nullable-stable-key": return { anyOf: [{ type: "null" }, { type: "string", pattern: STABLE_KEY_PATTERN.source }] };
|
|
355
388
|
case "hex-color": return { type: "string", pattern: HEX_COLOR_PATTERN.source };
|
|
356
389
|
case "hex-color-or-empty": return { type: "string", pattern: `^(?:|#[0-9a-fA-F]{6})$` };
|
|
357
390
|
case "safe-storage-relative-path": return { type: "string", minLength: 1 };
|
|
@@ -397,6 +430,7 @@
|
|
|
397
430
|
}
|
|
398
431
|
function jsonSchemaFor(templateId) {
|
|
399
432
|
const contract = getContract(templateId);
|
|
433
|
+
if (templateId === "ssobig.timeline") return { oneOf: [clone(schemaForShape(contract.root, contract)), clone(schemaForShape(EVENT_TIMELINE_CONTRACT.root, EVENT_TIMELINE_CONTRACT))] };
|
|
400
434
|
return contract ? clone(schemaForShape(contract.root, contract)) : null;
|
|
401
435
|
}
|
|
402
436
|
|
|
@@ -433,6 +467,8 @@
|
|
|
433
467
|
}
|
|
434
468
|
function validateField(value, definition, contract, path) {
|
|
435
469
|
switch (definition.type) {
|
|
470
|
+
case "nullable-uuid": return value === null || (typeof value === "string" && UUID_PATTERN.test(value)) ? null : `${path} 값은 UUID 또는 null이어야 합니다.`;
|
|
471
|
+
case "nullable-stable-key": return value === null || (typeof value === "string" && STABLE_KEY_PATTERN.test(value)) ? null : `${path} 값은 캐릭터 ID 또는 null이어야 합니다.`;
|
|
436
472
|
case "string": return typeof value === "string" ? null : `${path} 값은 문자열이어야 합니다.`;
|
|
437
473
|
case "non-empty-string": return typeof value === "string" && value.trim() ? null : `${path} 값은 비어 있지 않은 문자열이어야 합니다.`;
|
|
438
474
|
case "stable-key": return typeof value === "string" && STABLE_KEY_PATTERN.test(value) ? null : `${path} 값은 안정 식별자 형식이어야 합니다.`;
|
|
@@ -658,6 +694,7 @@
|
|
|
658
694
|
}
|
|
659
695
|
}
|
|
660
696
|
function validateTargetData(templateId, data) {
|
|
697
|
+
if (templateId === "ssobig.timeline" && isObject(data) && Object.hasOwn(data, "timeGroups")) return eventTimelineError(data);
|
|
661
698
|
const contract = getContract(templateId);
|
|
662
699
|
if (!contract) return `지원하지 않는 Component 계약입니다: ${templateId}`;
|
|
663
700
|
const invalid = validateShape(data, contract.root, contract, "$");
|
|
@@ -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);
|
|
@@ -152,7 +152,9 @@
|
|
|
152
152
|
// 타임라인의 모양 배타, 시간 레벨 참조, lane/when 종류별 규칙은 validateComponentData가 이미 검증한다.
|
|
153
153
|
// 여기서는 다른 Component를 봐야 판정할 수 있는 character 참조만 확인한다.
|
|
154
154
|
for (const timeline of instances.filter(instance => instance.templateId === "ssobig.timeline")) {
|
|
155
|
-
const invalid = Array.isArray(timeline.data.
|
|
155
|
+
const invalid = Array.isArray(timeline.data.timeGroups)
|
|
156
|
+
? (timeline.data.events.some(event => event.characterId !== null && !characterIds.has(event.characterId)) ? `${timeline.instanceId} 사건이 존재하지 않는 캐릭터를 가리킵니다.` : null)
|
|
157
|
+
: Array.isArray(timeline.data.events)
|
|
156
158
|
? legacyTimelineError(timeline, characterIds)
|
|
157
159
|
: timelineLaneCharacterError(timeline, characterIds);
|
|
158
160
|
if (invalid) return invalid;
|
|
@@ -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 });
|