@bendyline/squisq-editor-react 2.11.4 → 2.11.6
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/dist/chunk-DUO5XZQK.js +1653 -0
- package/dist/{chunk-CDISORBP.js → chunk-WMNH5ROU.js} +2799 -3761
- package/dist/index.d.ts +6 -54
- package/dist/index.js +13 -11
- package/dist/proofing/index.d.ts +2 -2
- package/dist/review/index.d.ts +285 -0
- package/dist/review/index.js +103 -0
- package/dist/shell/index.d.ts +4 -2
- package/dist/shell/index.js +5 -3
- package/dist/{shell-CJkHDwpz.d.ts → shell-Dwf_W4K4.d.ts} +116 -3
- package/dist/styles/index.css +166 -0
- package/dist/types-C0puuw-M.d.ts +81 -0
- package/dist/{types-B_aHRuSc.d.ts → types-De4z-gGb.d.ts} +1 -1
- package/package.json +10 -6
|
@@ -0,0 +1,1653 @@
|
|
|
1
|
+
import {
|
|
2
|
+
markdownToTiptap
|
|
3
|
+
} from "./chunk-ES6CPRCW.js";
|
|
4
|
+
|
|
5
|
+
// src/blockRange.ts
|
|
6
|
+
import { parseMarkdown } from "@bendyline/squisq/markdown";
|
|
7
|
+
|
|
8
|
+
// src/frontmatter.ts
|
|
9
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?(?:[ \t]*\r?\n)*/;
|
|
10
|
+
function stripFrontmatter(md) {
|
|
11
|
+
const m = md.match(FRONTMATTER_RE);
|
|
12
|
+
if (!m) return { body: md, frontmatter: "" };
|
|
13
|
+
return { body: md.slice(m[0].length), frontmatter: m[0] };
|
|
14
|
+
}
|
|
15
|
+
function frontmatterEndOffset(source) {
|
|
16
|
+
const m = source.match(FRONTMATTER_RE);
|
|
17
|
+
return m ? m[0].length : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/blockRange.ts
|
|
21
|
+
function computeLineStarts(source) {
|
|
22
|
+
const starts = [0];
|
|
23
|
+
for (let i = 0; i < source.length; i++) {
|
|
24
|
+
if (source[i] === "\n") starts.push(i + 1);
|
|
25
|
+
}
|
|
26
|
+
return starts;
|
|
27
|
+
}
|
|
28
|
+
function makeSlice(source, startOffset, endOffset) {
|
|
29
|
+
return { text: source.slice(startOffset, endOffset), range: { startOffset, endOffset } };
|
|
30
|
+
}
|
|
31
|
+
function getBlockSlices(fullSource) {
|
|
32
|
+
const bodyStart = frontmatterEndOffset(fullSource);
|
|
33
|
+
let doc;
|
|
34
|
+
try {
|
|
35
|
+
doc = parseMarkdown(fullSource);
|
|
36
|
+
} catch {
|
|
37
|
+
return [makeSlice(fullSource, bodyStart, fullSource.length)];
|
|
38
|
+
}
|
|
39
|
+
const lineStarts = computeLineStarts(fullSource);
|
|
40
|
+
const headingOffsets = [];
|
|
41
|
+
for (const node of doc.children) {
|
|
42
|
+
if (node.type !== "heading") continue;
|
|
43
|
+
const line = node.position?.start.line;
|
|
44
|
+
if (typeof line !== "number") continue;
|
|
45
|
+
const off = lineStarts[line - 1];
|
|
46
|
+
if (typeof off === "number") headingOffsets.push(off);
|
|
47
|
+
}
|
|
48
|
+
if (headingOffsets.length === 0) {
|
|
49
|
+
return [makeSlice(fullSource, bodyStart, fullSource.length)];
|
|
50
|
+
}
|
|
51
|
+
const slices = [];
|
|
52
|
+
const firstHeading = headingOffsets[0];
|
|
53
|
+
if (fullSource.slice(bodyStart, firstHeading).trim().length > 0) {
|
|
54
|
+
slices.push(makeSlice(fullSource, bodyStart, firstHeading));
|
|
55
|
+
}
|
|
56
|
+
for (let i = 0; i < headingOffsets.length; i++) {
|
|
57
|
+
const start = headingOffsets[i];
|
|
58
|
+
const end = i + 1 < headingOffsets.length ? headingOffsets[i + 1] : fullSource.length;
|
|
59
|
+
slices.push(makeSlice(fullSource, start, end));
|
|
60
|
+
}
|
|
61
|
+
return slices;
|
|
62
|
+
}
|
|
63
|
+
function spliceBlock(fullSource, range, newText) {
|
|
64
|
+
return fullSource.slice(0, range.startOffset) + newText + fullSource.slice(range.endOffset);
|
|
65
|
+
}
|
|
66
|
+
function lineToOffset(source, line) {
|
|
67
|
+
const starts = computeLineStarts(source);
|
|
68
|
+
return starts[Math.max(0, line - 1)] ?? source.length;
|
|
69
|
+
}
|
|
70
|
+
function offsetToLine(source, offset) {
|
|
71
|
+
const starts = computeLineStarts(source);
|
|
72
|
+
let lo = 0;
|
|
73
|
+
let hi = starts.length - 1;
|
|
74
|
+
let ans = 0;
|
|
75
|
+
while (lo <= hi) {
|
|
76
|
+
const mid = lo + hi >> 1;
|
|
77
|
+
if (starts[mid] <= offset) {
|
|
78
|
+
ans = mid;
|
|
79
|
+
lo = mid + 1;
|
|
80
|
+
} else {
|
|
81
|
+
hi = mid - 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ans + 1;
|
|
85
|
+
}
|
|
86
|
+
function sliceIndexAtOffset(slices, offset) {
|
|
87
|
+
return slices.findIndex((s) => offset >= s.range.startOffset && offset < s.range.endOffset);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/fileKind.ts
|
|
91
|
+
var DATA_MODE_EXTENSIONS = /* @__PURE__ */ new Set(["csv", "tsv", "parquet"]);
|
|
92
|
+
var EXT_TO_LANGUAGE = {
|
|
93
|
+
md: "markdown",
|
|
94
|
+
markdown: "markdown",
|
|
95
|
+
mdown: "markdown",
|
|
96
|
+
txt: "plaintext",
|
|
97
|
+
ts: "typescript",
|
|
98
|
+
tsx: "typescript",
|
|
99
|
+
js: "javascript",
|
|
100
|
+
jsx: "javascript",
|
|
101
|
+
mjs: "javascript",
|
|
102
|
+
cjs: "javascript",
|
|
103
|
+
json: "json",
|
|
104
|
+
jsonc: "json",
|
|
105
|
+
html: "html",
|
|
106
|
+
htm: "html",
|
|
107
|
+
css: "css",
|
|
108
|
+
scss: "scss",
|
|
109
|
+
less: "less",
|
|
110
|
+
py: "python",
|
|
111
|
+
go: "go",
|
|
112
|
+
rs: "rust",
|
|
113
|
+
rb: "ruby",
|
|
114
|
+
java: "java",
|
|
115
|
+
c: "c",
|
|
116
|
+
h: "c",
|
|
117
|
+
cpp: "cpp",
|
|
118
|
+
hpp: "cpp",
|
|
119
|
+
cc: "cpp",
|
|
120
|
+
cs: "csharp",
|
|
121
|
+
php: "php",
|
|
122
|
+
sh: "shell",
|
|
123
|
+
bash: "shell",
|
|
124
|
+
zsh: "shell",
|
|
125
|
+
yml: "yaml",
|
|
126
|
+
yaml: "yaml",
|
|
127
|
+
toml: "ini",
|
|
128
|
+
ini: "ini",
|
|
129
|
+
xml: "xml",
|
|
130
|
+
svg: "xml",
|
|
131
|
+
sql: "sql",
|
|
132
|
+
lua: "lua",
|
|
133
|
+
swift: "swift",
|
|
134
|
+
kt: "kotlin",
|
|
135
|
+
kts: "kotlin",
|
|
136
|
+
dockerfile: "dockerfile",
|
|
137
|
+
png: "image",
|
|
138
|
+
jpg: "image",
|
|
139
|
+
jpeg: "image",
|
|
140
|
+
gif: "image",
|
|
141
|
+
webp: "image",
|
|
142
|
+
bmp: "image",
|
|
143
|
+
ico: "image",
|
|
144
|
+
avif: "image"
|
|
145
|
+
};
|
|
146
|
+
var MARKDOWN_MODE_LANGUAGES = /* @__PURE__ */ new Set(["markdown", "plaintext"]);
|
|
147
|
+
var IMAGE_MODE_LANGUAGES = /* @__PURE__ */ new Set(["image"]);
|
|
148
|
+
var MONACO_LANGUAGE_ALIASES = {
|
|
149
|
+
jsonc: "json"
|
|
150
|
+
};
|
|
151
|
+
function extractExtension(fileName) {
|
|
152
|
+
const trimmed = fileName.trim();
|
|
153
|
+
if (!trimmed) return null;
|
|
154
|
+
const base = trimmed.replace(/^.*[/\\]/, "");
|
|
155
|
+
if (!base) return null;
|
|
156
|
+
const dotIdx = base.lastIndexOf(".");
|
|
157
|
+
if (dotIdx === -1) {
|
|
158
|
+
return base.toLowerCase();
|
|
159
|
+
}
|
|
160
|
+
if (dotIdx === base.length - 1) return null;
|
|
161
|
+
return base.slice(dotIdx + 1).toLowerCase();
|
|
162
|
+
}
|
|
163
|
+
function detectLanguageFromFileName(fileName) {
|
|
164
|
+
const ext = extractExtension(fileName);
|
|
165
|
+
if (!ext) return null;
|
|
166
|
+
return EXT_TO_LANGUAGE[ext] ?? null;
|
|
167
|
+
}
|
|
168
|
+
function resolveFileKind(fileName, language) {
|
|
169
|
+
if (!language && fileName) {
|
|
170
|
+
const ext = extractExtension(fileName);
|
|
171
|
+
if (ext && DATA_MODE_EXTENSIONS.has(ext)) {
|
|
172
|
+
return { mode: "data", language: "plaintext" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const requestedLanguage = language ?? (fileName ? detectLanguageFromFileName(fileName) : null);
|
|
176
|
+
const resolvedLanguage = requestedLanguage ? MONACO_LANGUAGE_ALIASES[requestedLanguage.toLowerCase()] ?? requestedLanguage : null;
|
|
177
|
+
if (!resolvedLanguage) {
|
|
178
|
+
return { mode: "markdown", language: "markdown" };
|
|
179
|
+
}
|
|
180
|
+
let mode;
|
|
181
|
+
if (IMAGE_MODE_LANGUAGES.has(resolvedLanguage)) {
|
|
182
|
+
mode = "image";
|
|
183
|
+
} else if (MARKDOWN_MODE_LANGUAGES.has(resolvedLanguage)) {
|
|
184
|
+
mode = "markdown";
|
|
185
|
+
} else {
|
|
186
|
+
mode = "code";
|
|
187
|
+
}
|
|
188
|
+
return { mode, language: resolvedLanguage };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/useBlockNavigator.ts
|
|
192
|
+
import { useCallback, useMemo, useState } from "react";
|
|
193
|
+
var NEW_BLOCK_MARKDOWN = "## New section\n\n";
|
|
194
|
+
function useBlockNavigator(source, setSource, opts) {
|
|
195
|
+
const enabled = opts?.enabled ?? false;
|
|
196
|
+
const slices = useMemo(() => getBlockSlices(source), [source]);
|
|
197
|
+
const blockCount = slices.length;
|
|
198
|
+
const [rawKey, setRawKey] = useState(0);
|
|
199
|
+
const activeBlockKey = blockCount === 0 ? 0 : Math.min(Math.max(rawKey, 0), blockCount - 1);
|
|
200
|
+
const active = slices[activeBlockKey] ?? null;
|
|
201
|
+
const goToBlock = useCallback((key) => {
|
|
202
|
+
setRawKey(key);
|
|
203
|
+
}, []);
|
|
204
|
+
const prevBlock = useCallback(() => {
|
|
205
|
+
setRawKey(activeBlockKey - 1);
|
|
206
|
+
}, [activeBlockKey]);
|
|
207
|
+
const nextBlock = useCallback(() => {
|
|
208
|
+
setRawKey(activeBlockKey + 1);
|
|
209
|
+
}, [activeBlockKey]);
|
|
210
|
+
const goToBlockByLine = useCallback(
|
|
211
|
+
(line) => {
|
|
212
|
+
const offset = lineToOffset(source, line);
|
|
213
|
+
const idx = sliceIndexAtOffset(slices, offset);
|
|
214
|
+
if (idx >= 0) setRawKey(idx);
|
|
215
|
+
},
|
|
216
|
+
[source, slices]
|
|
217
|
+
);
|
|
218
|
+
const activeBlockStartLine = active ? offsetToLine(source, active.range.startOffset) : null;
|
|
219
|
+
const editorSource = enabled && active ? active.text : source;
|
|
220
|
+
const setEditorSource = useCallback(
|
|
221
|
+
(next) => {
|
|
222
|
+
if (!enabled || !active) {
|
|
223
|
+
setSource(next);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const hasFollowing = active.range.endOffset < source.length;
|
|
227
|
+
const normalized = hasFollowing ? next.replace(/\s*$/, "") + "\n\n" : next;
|
|
228
|
+
setSource(spliceBlock(source, active.range, normalized));
|
|
229
|
+
},
|
|
230
|
+
[enabled, active, source, setSource]
|
|
231
|
+
);
|
|
232
|
+
const addBlock = useCallback(() => {
|
|
233
|
+
if (!active) {
|
|
234
|
+
const base = source.replace(/\s*$/, "");
|
|
235
|
+
setSource((base ? base + "\n\n" : "") + NEW_BLOCK_MARKDOWN);
|
|
236
|
+
setRawKey(blockCount);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const insertAt = active.range.endOffset;
|
|
240
|
+
const before = source.slice(0, insertAt);
|
|
241
|
+
const after = source.slice(insertAt);
|
|
242
|
+
const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
243
|
+
setSource(before + lead + NEW_BLOCK_MARKDOWN + after);
|
|
244
|
+
setRawKey(activeBlockKey + 1);
|
|
245
|
+
}, [active, source, setSource, activeBlockKey, blockCount]);
|
|
246
|
+
return {
|
|
247
|
+
editorSource,
|
|
248
|
+
setEditorSource,
|
|
249
|
+
blockCount,
|
|
250
|
+
activeBlockKey,
|
|
251
|
+
goToBlock,
|
|
252
|
+
goToBlockByLine,
|
|
253
|
+
activeBlockStartLine,
|
|
254
|
+
prevBlock,
|
|
255
|
+
nextBlock,
|
|
256
|
+
addBlock
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/EditorContext.tsx
|
|
261
|
+
import {
|
|
262
|
+
createContext,
|
|
263
|
+
useContext,
|
|
264
|
+
useState as useState2,
|
|
265
|
+
useCallback as useCallback2,
|
|
266
|
+
useMemo as useMemo2,
|
|
267
|
+
useRef,
|
|
268
|
+
useEffect
|
|
269
|
+
} from "react";
|
|
270
|
+
import { parseMarkdown as parseMarkdown2, stringifyMarkdown } from "@bendyline/squisq/markdown";
|
|
271
|
+
import { markdownToDoc } from "@bendyline/squisq/doc";
|
|
272
|
+
import {
|
|
273
|
+
DocumentVersionManager
|
|
274
|
+
} from "@bendyline/squisq/versions";
|
|
275
|
+
|
|
276
|
+
// src/scene/text/sceneTextChannel.ts
|
|
277
|
+
function createSceneTextChannel() {
|
|
278
|
+
let current = null;
|
|
279
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
280
|
+
return {
|
|
281
|
+
set(handle) {
|
|
282
|
+
current = handle;
|
|
283
|
+
for (const listener of listeners) listener(handle);
|
|
284
|
+
},
|
|
285
|
+
get() {
|
|
286
|
+
return current;
|
|
287
|
+
},
|
|
288
|
+
subscribe(listener) {
|
|
289
|
+
listeners.add(listener);
|
|
290
|
+
return () => listeners.delete(listener);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/editorScrollSync.ts
|
|
296
|
+
function clampRatio(value) {
|
|
297
|
+
if (!Number.isFinite(value)) return 0;
|
|
298
|
+
return Math.min(1, Math.max(0, value));
|
|
299
|
+
}
|
|
300
|
+
function scrollRatio(scrollTop, scrollHeight, viewportHeight) {
|
|
301
|
+
const availableScroll = scrollHeight - viewportHeight;
|
|
302
|
+
if (availableScroll <= 0) return 0;
|
|
303
|
+
return clampRatio(scrollTop / availableScroll);
|
|
304
|
+
}
|
|
305
|
+
function scrollTopForRatio(ratio, scrollHeight, viewportHeight) {
|
|
306
|
+
return clampRatio(ratio) * Math.max(0, scrollHeight - viewportHeight);
|
|
307
|
+
}
|
|
308
|
+
function readMonacoScrollRatio(editor) {
|
|
309
|
+
return scrollRatio(
|
|
310
|
+
editor.getScrollTop(),
|
|
311
|
+
editor.getScrollHeight(),
|
|
312
|
+
editor.getLayoutInfo().height
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
function restoreMonacoScrollRatio(editor, ratio) {
|
|
316
|
+
editor.setScrollTop(
|
|
317
|
+
scrollTopForRatio(ratio, editor.getScrollHeight(), editor.getLayoutInfo().height)
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
function findWysiwygScrollElement(editor) {
|
|
321
|
+
return editor.view.dom.closest(".squisq-wysiwyg-container");
|
|
322
|
+
}
|
|
323
|
+
function readWysiwygScrollRatio(editor) {
|
|
324
|
+
const element = findWysiwygScrollElement(editor);
|
|
325
|
+
if (!element) return null;
|
|
326
|
+
return scrollRatio(element.scrollTop, element.scrollHeight, element.clientHeight);
|
|
327
|
+
}
|
|
328
|
+
function restoreWysiwygScrollRatio(editor, ratio) {
|
|
329
|
+
const element = findWysiwygScrollElement(editor);
|
|
330
|
+
if (!element) return false;
|
|
331
|
+
element.scrollTop = scrollTopForRatio(ratio, element.scrollHeight, element.clientHeight);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/EditorContext.tsx
|
|
336
|
+
import { jsx } from "react/jsx-runtime";
|
|
337
|
+
var EditorContext = createContext(null);
|
|
338
|
+
function useEditorContext() {
|
|
339
|
+
const ctx = useContext(EditorContext);
|
|
340
|
+
if (!ctx) {
|
|
341
|
+
throw new Error("useEditorContext must be used within an <EditorProvider>");
|
|
342
|
+
}
|
|
343
|
+
return ctx;
|
|
344
|
+
}
|
|
345
|
+
var DEFAULT_PRUNE_POLICY = { type: "keep-last-n", n: 50 };
|
|
346
|
+
var DEFAULT_AUTOSAVE_IDLE_MS = 5e3;
|
|
347
|
+
function normalizeBlockTagVisibility(value) {
|
|
348
|
+
if (value === false || value === "none") return "none";
|
|
349
|
+
if (value === void 0 || value === "active") return "active";
|
|
350
|
+
return "always";
|
|
351
|
+
}
|
|
352
|
+
function EditorProvider({
|
|
353
|
+
initialMarkdown = "",
|
|
354
|
+
initialView = "raw",
|
|
355
|
+
articleId = "untitled",
|
|
356
|
+
colorScheme: initialColorScheme = "light",
|
|
357
|
+
workspaceContainer = null,
|
|
358
|
+
allowVersioning = false,
|
|
359
|
+
versionBasename,
|
|
360
|
+
versioningPrunePolicy = DEFAULT_PRUNE_POLICY,
|
|
361
|
+
versioningAutoSaveIdleMs = DEFAULT_AUTOSAVE_IDLE_MS,
|
|
362
|
+
onSaveVersion,
|
|
363
|
+
mediaProvider = null,
|
|
364
|
+
imageDisplayMode = "inline",
|
|
365
|
+
mentionProvider = null,
|
|
366
|
+
proofing = null,
|
|
367
|
+
proofingDefaultEnabled = true,
|
|
368
|
+
proofingSpellingEnabled = true,
|
|
369
|
+
proofingGrammarEnabled = true,
|
|
370
|
+
proofingIgnoreStore = null,
|
|
371
|
+
documentLinkProvider = null,
|
|
372
|
+
fenceRenderers = null,
|
|
373
|
+
onCopyCode,
|
|
374
|
+
linkSchemes,
|
|
375
|
+
allowRecording = true,
|
|
376
|
+
allowNarrate = true,
|
|
377
|
+
preserveSourceWrapping = true,
|
|
378
|
+
fileName,
|
|
379
|
+
saveCoverImageOutput,
|
|
380
|
+
saveDashboardImageOutput,
|
|
381
|
+
language,
|
|
382
|
+
findMode: controlledFindMode,
|
|
383
|
+
onFindModeChange,
|
|
384
|
+
inlinePreview = false,
|
|
385
|
+
showStatusBar = true,
|
|
386
|
+
outline = false,
|
|
387
|
+
blockTags,
|
|
388
|
+
blockTagVisibility: initialBlockTagVisibility,
|
|
389
|
+
themeInheritance = "fonts",
|
|
390
|
+
layoutMode = "document",
|
|
391
|
+
viewPreferences,
|
|
392
|
+
onViewPreferencesChange,
|
|
393
|
+
children
|
|
394
|
+
}) {
|
|
395
|
+
const effectiveInlinePreview = viewPreferences?.inlinePreview ?? inlinePreview;
|
|
396
|
+
const effectiveShowStatusBar = viewPreferences?.showStatusBar ?? showStatusBar;
|
|
397
|
+
const effectiveOutline = viewPreferences?.outline ?? outline;
|
|
398
|
+
const effectiveBlockTagVisibility = normalizeBlockTagVisibility(
|
|
399
|
+
viewPreferences?.blockTagVisibility ?? viewPreferences?.blockTags ?? initialBlockTagVisibility ?? blockTags
|
|
400
|
+
);
|
|
401
|
+
const effectiveThemeInheritance = viewPreferences?.themeInheritance ?? themeInheritance;
|
|
402
|
+
const effectiveLayoutMode = viewPreferences?.layoutMode ?? layoutMode;
|
|
403
|
+
const { mode: fileMode, language: resolvedLanguage } = useMemo2(
|
|
404
|
+
() => resolveFileKind(fileName, language),
|
|
405
|
+
[fileName, language]
|
|
406
|
+
);
|
|
407
|
+
const editorMode = fileMode === "data" ? "code" : fileMode;
|
|
408
|
+
const [markdownSource, setMarkdownSourceRaw] = useState2(initialMarkdown);
|
|
409
|
+
const [markdownDoc, setMarkdownDocState] = useState2(null);
|
|
410
|
+
const [doc, setDoc] = useState2(null);
|
|
411
|
+
const [activeView, setActiveViewRaw] = useState2(
|
|
412
|
+
editorMode === "markdown" ? initialView : "raw"
|
|
413
|
+
);
|
|
414
|
+
const [uncontrolledFindMode, setUncontrolledFindMode] = useState2(false);
|
|
415
|
+
const findMode = editorMode !== "image" && (controlledFindMode ?? uncontrolledFindMode);
|
|
416
|
+
const setFindMode = useCallback2(
|
|
417
|
+
(active) => {
|
|
418
|
+
if (editorMode === "image") return;
|
|
419
|
+
if (controlledFindMode === void 0) setUncontrolledFindMode(active);
|
|
420
|
+
onFindModeChange?.(active);
|
|
421
|
+
},
|
|
422
|
+
[controlledFindMode, editorMode, onFindModeChange]
|
|
423
|
+
);
|
|
424
|
+
const activeViewRef = useRef(activeView);
|
|
425
|
+
activeViewRef.current = activeView;
|
|
426
|
+
const tiptapEditorRef = useRef(null);
|
|
427
|
+
const monacoEditorRef = useRef(null);
|
|
428
|
+
const pendingEditorScrollRef = useRef(null);
|
|
429
|
+
const setActiveView = useCallback2(
|
|
430
|
+
(view) => {
|
|
431
|
+
if (editorMode === "code" && view !== "raw") return;
|
|
432
|
+
if (editorMode === "image") return;
|
|
433
|
+
const currentView = activeViewRef.current;
|
|
434
|
+
if (view === currentView) return;
|
|
435
|
+
let ratio = null;
|
|
436
|
+
let targetView = null;
|
|
437
|
+
if (currentView === "raw" && view === "wysiwyg" && monacoEditorRef.current) {
|
|
438
|
+
ratio = readMonacoScrollRatio(monacoEditorRef.current);
|
|
439
|
+
targetView = "wysiwyg";
|
|
440
|
+
} else if (currentView === "wysiwyg" && view === "raw" && tiptapEditorRef.current) {
|
|
441
|
+
ratio = readWysiwygScrollRatio(tiptapEditorRef.current);
|
|
442
|
+
targetView = "raw";
|
|
443
|
+
}
|
|
444
|
+
pendingEditorScrollRef.current = ratio === null || targetView === null ? null : { view: targetView, ratio };
|
|
445
|
+
setActiveViewRaw(view);
|
|
446
|
+
},
|
|
447
|
+
[editorMode]
|
|
448
|
+
);
|
|
449
|
+
const [parseError, setParseError] = useState2(null);
|
|
450
|
+
const [isParsing, setIsParsing] = useState2(false);
|
|
451
|
+
const [colorScheme, setColorScheme] = useState2(initialColorScheme);
|
|
452
|
+
const [inlinePreviewVisible, setInlinePreviewVisibleRaw] = useState2(effectiveInlinePreview);
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (viewPreferences?.inlinePreview === void 0) setInlinePreviewVisibleRaw(inlinePreview);
|
|
455
|
+
}, [inlinePreview, viewPreferences?.inlinePreview]);
|
|
456
|
+
const [statusBarVisible, setStatusBarVisibleRaw] = useState2(effectiveShowStatusBar);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (viewPreferences?.showStatusBar === void 0) setStatusBarVisibleRaw(showStatusBar);
|
|
459
|
+
}, [showStatusBar, viewPreferences?.showStatusBar]);
|
|
460
|
+
const [outlineVisible, setOutlineVisibleRaw] = useState2(effectiveOutline);
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
if (viewPreferences?.outline === void 0) setOutlineVisibleRaw(outline);
|
|
463
|
+
}, [outline, viewPreferences?.outline]);
|
|
464
|
+
const [blockTagVisibilityState, setBlockTagVisibilityRaw] = useState2(
|
|
465
|
+
effectiveBlockTagVisibility
|
|
466
|
+
);
|
|
467
|
+
const blockTagsVisible = blockTagVisibilityState !== "none";
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
if (viewPreferences?.blockTagVisibility === void 0 && viewPreferences?.blockTags === void 0) {
|
|
470
|
+
setBlockTagVisibilityRaw(normalizeBlockTagVisibility(initialBlockTagVisibility ?? blockTags));
|
|
471
|
+
}
|
|
472
|
+
}, [blockTags, initialBlockTagVisibility, viewPreferences]);
|
|
473
|
+
const [themeInheritanceState, setThemeInheritanceRaw] = useState2(effectiveThemeInheritance);
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
if (viewPreferences?.themeInheritance === void 0) {
|
|
476
|
+
setThemeInheritanceRaw(themeInheritance);
|
|
477
|
+
}
|
|
478
|
+
}, [themeInheritance, viewPreferences?.themeInheritance]);
|
|
479
|
+
const [layoutModeState, setLayoutModeRaw] = useState2(effectiveLayoutMode);
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (viewPreferences?.layoutMode === void 0) setLayoutModeRaw(layoutMode);
|
|
482
|
+
}, [layoutMode, viewPreferences?.layoutMode]);
|
|
483
|
+
const [imageEditTarget, setImageEditTarget] = useState2(null);
|
|
484
|
+
const [mediaRevision, setMediaRevision] = useState2(0);
|
|
485
|
+
const openImageEdit = useCallback2((relativePath) => {
|
|
486
|
+
setImageEditTarget(relativePath);
|
|
487
|
+
}, []);
|
|
488
|
+
const closeImageEdit = useCallback2(() => {
|
|
489
|
+
setImageEditTarget(null);
|
|
490
|
+
}, []);
|
|
491
|
+
const bumpMediaRevision = useCallback2(() => {
|
|
492
|
+
setMediaRevision((n) => n + 1);
|
|
493
|
+
}, []);
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
if (!viewPreferences) return;
|
|
496
|
+
if (viewPreferences.inlinePreview !== void 0) {
|
|
497
|
+
setInlinePreviewVisibleRaw(viewPreferences.inlinePreview);
|
|
498
|
+
}
|
|
499
|
+
if (viewPreferences.showStatusBar !== void 0) {
|
|
500
|
+
setStatusBarVisibleRaw(viewPreferences.showStatusBar);
|
|
501
|
+
}
|
|
502
|
+
if (viewPreferences.outline !== void 0) {
|
|
503
|
+
setOutlineVisibleRaw(viewPreferences.outline);
|
|
504
|
+
}
|
|
505
|
+
if (viewPreferences.blockTagVisibility !== void 0) {
|
|
506
|
+
setBlockTagVisibilityRaw(normalizeBlockTagVisibility(viewPreferences.blockTagVisibility));
|
|
507
|
+
} else if (viewPreferences.blockTags !== void 0) {
|
|
508
|
+
setBlockTagVisibilityRaw(normalizeBlockTagVisibility(viewPreferences.blockTags));
|
|
509
|
+
}
|
|
510
|
+
if (viewPreferences.themeInheritance !== void 0) {
|
|
511
|
+
setThemeInheritanceRaw(viewPreferences.themeInheritance);
|
|
512
|
+
}
|
|
513
|
+
if (viewPreferences.layoutMode !== void 0) {
|
|
514
|
+
setLayoutModeRaw(viewPreferences.layoutMode);
|
|
515
|
+
}
|
|
516
|
+
}, [viewPreferences]);
|
|
517
|
+
const onViewPreferencesChangeRef = useRef(onViewPreferencesChange);
|
|
518
|
+
onViewPreferencesChangeRef.current = onViewPreferencesChange;
|
|
519
|
+
const inlinePreviewRef = useRef(inlinePreviewVisible);
|
|
520
|
+
inlinePreviewRef.current = inlinePreviewVisible;
|
|
521
|
+
const statusBarRef = useRef(statusBarVisible);
|
|
522
|
+
statusBarRef.current = statusBarVisible;
|
|
523
|
+
const outlineRef = useRef(outlineVisible);
|
|
524
|
+
outlineRef.current = outlineVisible;
|
|
525
|
+
const blockTagVisibilityRef = useRef(blockTagVisibilityState);
|
|
526
|
+
blockTagVisibilityRef.current = blockTagVisibilityState;
|
|
527
|
+
const themeInheritanceRef = useRef(themeInheritanceState);
|
|
528
|
+
themeInheritanceRef.current = themeInheritanceState;
|
|
529
|
+
const layoutModeRef = useRef(layoutModeState);
|
|
530
|
+
layoutModeRef.current = layoutModeState;
|
|
531
|
+
const setInlinePreviewVisible = useCallback2((visible) => {
|
|
532
|
+
setInlinePreviewVisibleRaw(visible);
|
|
533
|
+
onViewPreferencesChangeRef.current?.({
|
|
534
|
+
inlinePreview: visible,
|
|
535
|
+
showStatusBar: statusBarRef.current,
|
|
536
|
+
outline: outlineRef.current,
|
|
537
|
+
blockTags: blockTagVisibilityRef.current !== "none",
|
|
538
|
+
blockTagVisibility: blockTagVisibilityRef.current,
|
|
539
|
+
themeInheritance: themeInheritanceRef.current,
|
|
540
|
+
layoutMode: layoutModeRef.current
|
|
541
|
+
});
|
|
542
|
+
}, []);
|
|
543
|
+
const setStatusBarVisible = useCallback2((visible) => {
|
|
544
|
+
setStatusBarVisibleRaw(visible);
|
|
545
|
+
onViewPreferencesChangeRef.current?.({
|
|
546
|
+
inlinePreview: inlinePreviewRef.current,
|
|
547
|
+
showStatusBar: visible,
|
|
548
|
+
outline: outlineRef.current,
|
|
549
|
+
blockTags: blockTagVisibilityRef.current !== "none",
|
|
550
|
+
blockTagVisibility: blockTagVisibilityRef.current,
|
|
551
|
+
themeInheritance: themeInheritanceRef.current,
|
|
552
|
+
layoutMode: layoutModeRef.current
|
|
553
|
+
});
|
|
554
|
+
}, []);
|
|
555
|
+
const setOutlineVisible = useCallback2((visible) => {
|
|
556
|
+
setOutlineVisibleRaw(visible);
|
|
557
|
+
onViewPreferencesChangeRef.current?.({
|
|
558
|
+
inlinePreview: inlinePreviewRef.current,
|
|
559
|
+
showStatusBar: statusBarRef.current,
|
|
560
|
+
outline: visible,
|
|
561
|
+
blockTags: blockTagVisibilityRef.current !== "none",
|
|
562
|
+
blockTagVisibility: blockTagVisibilityRef.current,
|
|
563
|
+
themeInheritance: themeInheritanceRef.current,
|
|
564
|
+
layoutMode: layoutModeRef.current
|
|
565
|
+
});
|
|
566
|
+
}, []);
|
|
567
|
+
const setBlockTagVisibility = useCallback2((visibility) => {
|
|
568
|
+
setBlockTagVisibilityRaw(visibility);
|
|
569
|
+
onViewPreferencesChangeRef.current?.({
|
|
570
|
+
inlinePreview: inlinePreviewRef.current,
|
|
571
|
+
showStatusBar: statusBarRef.current,
|
|
572
|
+
outline: outlineRef.current,
|
|
573
|
+
blockTags: visibility !== "none",
|
|
574
|
+
blockTagVisibility: visibility,
|
|
575
|
+
themeInheritance: themeInheritanceRef.current,
|
|
576
|
+
layoutMode: layoutModeRef.current
|
|
577
|
+
});
|
|
578
|
+
}, []);
|
|
579
|
+
const setBlockTagsVisible = useCallback2(
|
|
580
|
+
(visible) => setBlockTagVisibility(visible ? "always" : "none"),
|
|
581
|
+
[setBlockTagVisibility]
|
|
582
|
+
);
|
|
583
|
+
const setThemeInheritance = useCallback2((mode) => {
|
|
584
|
+
setThemeInheritanceRaw(mode);
|
|
585
|
+
onViewPreferencesChangeRef.current?.({
|
|
586
|
+
inlinePreview: inlinePreviewRef.current,
|
|
587
|
+
showStatusBar: statusBarRef.current,
|
|
588
|
+
outline: outlineRef.current,
|
|
589
|
+
blockTags: blockTagVisibilityRef.current !== "none",
|
|
590
|
+
blockTagVisibility: blockTagVisibilityRef.current,
|
|
591
|
+
themeInheritance: mode,
|
|
592
|
+
layoutMode: layoutModeRef.current
|
|
593
|
+
});
|
|
594
|
+
}, []);
|
|
595
|
+
const setLayoutMode = useCallback2((mode) => {
|
|
596
|
+
setLayoutModeRaw(mode);
|
|
597
|
+
onViewPreferencesChangeRef.current?.({
|
|
598
|
+
inlinePreview: inlinePreviewRef.current,
|
|
599
|
+
showStatusBar: statusBarRef.current,
|
|
600
|
+
outline: outlineRef.current,
|
|
601
|
+
blockTags: blockTagVisibilityRef.current !== "none",
|
|
602
|
+
blockTagVisibility: blockTagVisibilityRef.current,
|
|
603
|
+
themeInheritance: themeInheritanceRef.current,
|
|
604
|
+
layoutMode: mode
|
|
605
|
+
});
|
|
606
|
+
}, []);
|
|
607
|
+
const [tiptapEditor, setTiptapEditorRaw] = useState2(null);
|
|
608
|
+
const [monacoEditor, setMonacoEditorRaw] = useState2(null);
|
|
609
|
+
const setTiptapEditor = useCallback2((editor) => {
|
|
610
|
+
tiptapEditorRef.current = editor;
|
|
611
|
+
setTiptapEditorRaw(editor);
|
|
612
|
+
}, []);
|
|
613
|
+
const setMonacoEditor = useCallback2((editor) => {
|
|
614
|
+
monacoEditorRef.current = editor;
|
|
615
|
+
setMonacoEditorRaw(editor);
|
|
616
|
+
}, []);
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
const pending = pendingEditorScrollRef.current;
|
|
619
|
+
if (!pending || pending.view !== activeView) return;
|
|
620
|
+
const restore = activeView === "raw" && monacoEditor ? () => restoreMonacoScrollRatio(monacoEditor, pending.ratio) : activeView === "wysiwyg" && tiptapEditor ? () => restoreWysiwygScrollRatio(tiptapEditor, pending.ratio) : null;
|
|
621
|
+
if (!restore) return;
|
|
622
|
+
let cancelled = false;
|
|
623
|
+
const apply = () => {
|
|
624
|
+
if (cancelled) return;
|
|
625
|
+
restore();
|
|
626
|
+
if (pendingEditorScrollRef.current === pending) pendingEditorScrollRef.current = null;
|
|
627
|
+
};
|
|
628
|
+
if (typeof requestAnimationFrame === "function") {
|
|
629
|
+
const frame = requestAnimationFrame(apply);
|
|
630
|
+
return () => {
|
|
631
|
+
cancelled = true;
|
|
632
|
+
cancelAnimationFrame(frame);
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const timeout = setTimeout(apply, 0);
|
|
636
|
+
return () => {
|
|
637
|
+
cancelled = true;
|
|
638
|
+
clearTimeout(timeout);
|
|
639
|
+
};
|
|
640
|
+
}, [activeView, monacoEditor, tiptapEditor]);
|
|
641
|
+
const [activeSceneText, setActiveSceneText] = useState2(null);
|
|
642
|
+
const sceneTextChannel = useMemo2(createSceneTextChannel, []);
|
|
643
|
+
useEffect(() => sceneTextChannel.subscribe(setActiveSceneText), [sceneTextChannel]);
|
|
644
|
+
const articleIdRef = useRef(articleId);
|
|
645
|
+
articleIdRef.current = articleId;
|
|
646
|
+
const fileNameRef = useRef(fileName);
|
|
647
|
+
fileNameRef.current = fileName;
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
setColorScheme(initialColorScheme);
|
|
650
|
+
}, [initialColorScheme]);
|
|
651
|
+
const parseTimeoutRef = useRef(null);
|
|
652
|
+
const skipInitialDebouncedParseRef = useRef(Boolean(initialMarkdown));
|
|
653
|
+
const doParse = useCallback2((source) => {
|
|
654
|
+
setIsParsing(true);
|
|
655
|
+
try {
|
|
656
|
+
const parsed = parseMarkdown2(source);
|
|
657
|
+
setMarkdownDocState(parsed);
|
|
658
|
+
setParseError(null);
|
|
659
|
+
try {
|
|
660
|
+
const generatedDoc = markdownToDoc(parsed, {
|
|
661
|
+
articleId: articleIdRef.current,
|
|
662
|
+
fileName: fileNameRef.current
|
|
663
|
+
});
|
|
664
|
+
setDoc(generatedDoc);
|
|
665
|
+
} catch (docErr) {
|
|
666
|
+
setDoc(null);
|
|
667
|
+
console.warn("Doc generation failed:", docErr instanceof Error ? docErr.message : docErr);
|
|
668
|
+
}
|
|
669
|
+
} catch (err) {
|
|
670
|
+
setParseError(err instanceof Error ? err.message : "Parse error");
|
|
671
|
+
setMarkdownDocState(null);
|
|
672
|
+
setDoc(null);
|
|
673
|
+
} finally {
|
|
674
|
+
setIsParsing(false);
|
|
675
|
+
}
|
|
676
|
+
}, []);
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
if (editorMode !== "markdown") return;
|
|
679
|
+
if (skipInitialDebouncedParseRef.current) {
|
|
680
|
+
skipInitialDebouncedParseRef.current = false;
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (parseTimeoutRef.current) {
|
|
684
|
+
clearTimeout(parseTimeoutRef.current);
|
|
685
|
+
}
|
|
686
|
+
setIsParsing(true);
|
|
687
|
+
parseTimeoutRef.current = setTimeout(() => {
|
|
688
|
+
doParse(markdownSource);
|
|
689
|
+
}, 150);
|
|
690
|
+
return () => {
|
|
691
|
+
if (parseTimeoutRef.current) {
|
|
692
|
+
clearTimeout(parseTimeoutRef.current);
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
}, [markdownSource, doParse, editorMode]);
|
|
696
|
+
useEffect(() => {
|
|
697
|
+
if (editorMode !== "markdown") return;
|
|
698
|
+
if (initialMarkdown) {
|
|
699
|
+
doParse(initialMarkdown);
|
|
700
|
+
}
|
|
701
|
+
}, []);
|
|
702
|
+
const setMarkdownSource = useCallback2((source) => {
|
|
703
|
+
setMarkdownSourceRaw(source);
|
|
704
|
+
}, []);
|
|
705
|
+
const blockNav = useBlockNavigator(markdownSource, setMarkdownSource, {
|
|
706
|
+
enabled: (layoutModeState === "block" || layoutModeState === "timeline") && editorMode === "markdown"
|
|
707
|
+
});
|
|
708
|
+
const {
|
|
709
|
+
editorSource,
|
|
710
|
+
setEditorSource,
|
|
711
|
+
blockCount,
|
|
712
|
+
activeBlockKey,
|
|
713
|
+
activeBlockStartLine,
|
|
714
|
+
goToBlock,
|
|
715
|
+
goToBlockByLine,
|
|
716
|
+
prevBlock,
|
|
717
|
+
nextBlock,
|
|
718
|
+
addBlock
|
|
719
|
+
} = blockNav;
|
|
720
|
+
const [selectionVersion, setSelectionVersion] = useState2(0);
|
|
721
|
+
useEffect(() => {
|
|
722
|
+
const bump = () => setSelectionVersion((version) => version + 1);
|
|
723
|
+
const disposers = [];
|
|
724
|
+
if (typeof tiptapEditor?.on === "function" && typeof tiptapEditor.off === "function") {
|
|
725
|
+
tiptapEditor.on("selectionUpdate", bump);
|
|
726
|
+
disposers.push(() => tiptapEditor.off("selectionUpdate", bump));
|
|
727
|
+
}
|
|
728
|
+
if (typeof monacoEditor?.onDidChangeCursorSelection === "function") {
|
|
729
|
+
const subscription = monacoEditor.onDidChangeCursorSelection(bump);
|
|
730
|
+
if (typeof subscription?.dispose === "function") {
|
|
731
|
+
disposers.push(() => subscription.dispose());
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return () => {
|
|
735
|
+
for (const dispose of disposers) dispose();
|
|
736
|
+
};
|
|
737
|
+
}, [tiptapEditor, monacoEditor]);
|
|
738
|
+
const getSelection = useCallback2(() => {
|
|
739
|
+
if (activeView === "wysiwyg" && tiptapEditor) {
|
|
740
|
+
const { from, to, empty } = tiptapEditor.state.selection;
|
|
741
|
+
return {
|
|
742
|
+
view: "wysiwyg",
|
|
743
|
+
text: empty ? "" : tiptapEditor.state.doc.textBetween(from, to, "\n", " "),
|
|
744
|
+
empty
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (activeView === "raw" && monacoEditor) {
|
|
748
|
+
const selection = monacoEditor.getSelection();
|
|
749
|
+
const model = monacoEditor.getModel();
|
|
750
|
+
if (!selection || !model) return null;
|
|
751
|
+
const text = model.getValueInRange(selection);
|
|
752
|
+
return { view: "raw", text, empty: text.length === 0 };
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}, [activeView, tiptapEditor, monacoEditor]);
|
|
756
|
+
const replaceSelection = useCallback2(
|
|
757
|
+
(markdown) => {
|
|
758
|
+
if (activeView === "wysiwyg" && tiptapEditor) {
|
|
759
|
+
tiptapEditor.chain().focus().insertContent(markdownToTiptap(markdown)).run();
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
if (activeView === "raw" && monacoEditor) {
|
|
763
|
+
const selection = monacoEditor.getSelection();
|
|
764
|
+
if (!selection) return false;
|
|
765
|
+
monacoEditor.pushUndoStop();
|
|
766
|
+
monacoEditor.executeEdits("replace-selection", [{ range: selection, text: markdown }]);
|
|
767
|
+
monacoEditor.pushUndoStop();
|
|
768
|
+
monacoEditor.focus();
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
return false;
|
|
772
|
+
},
|
|
773
|
+
[activeView, tiptapEditor, monacoEditor]
|
|
774
|
+
);
|
|
775
|
+
const getBlockAtCursor = useCallback2(() => {
|
|
776
|
+
if (activeView !== "raw" || !monacoEditor) return null;
|
|
777
|
+
const model = monacoEditor.getModel();
|
|
778
|
+
const position = monacoEditor.getPosition();
|
|
779
|
+
if (!model || !position) return null;
|
|
780
|
+
const slices = getBlockSlices(model.getValue());
|
|
781
|
+
if (slices.length === 0) return null;
|
|
782
|
+
return slices[sliceIndexAtOffset(slices, model.getOffsetAt(position))] ?? null;
|
|
783
|
+
}, [activeView, monacoEditor]);
|
|
784
|
+
const insertAtCursor = useCallback2(
|
|
785
|
+
(text) => {
|
|
786
|
+
if (activeView === "wysiwyg" && tiptapEditor) {
|
|
787
|
+
const html = markdownToTiptap(text);
|
|
788
|
+
tiptapEditor.chain().focus().insertContent(html).run();
|
|
789
|
+
} else if (activeView === "raw" && monacoEditor) {
|
|
790
|
+
const position = monacoEditor.getPosition();
|
|
791
|
+
if (position) {
|
|
792
|
+
const model = monacoEditor.getModel();
|
|
793
|
+
if (model) {
|
|
794
|
+
const range = {
|
|
795
|
+
startLineNumber: position.lineNumber,
|
|
796
|
+
startColumn: position.column,
|
|
797
|
+
endLineNumber: position.lineNumber,
|
|
798
|
+
endColumn: position.column
|
|
799
|
+
};
|
|
800
|
+
monacoEditor.executeEdits("drop", [{ range, text }]);
|
|
801
|
+
}
|
|
802
|
+
} else {
|
|
803
|
+
setMarkdownSourceRaw((prev) => prev + "\n\n" + text);
|
|
804
|
+
}
|
|
805
|
+
} else {
|
|
806
|
+
setMarkdownSourceRaw((prev) => prev + "\n\n" + text);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
[activeView, tiptapEditor, monacoEditor]
|
|
810
|
+
);
|
|
811
|
+
const replaceAll = useCallback2((text) => {
|
|
812
|
+
setMarkdownSourceRaw(text);
|
|
813
|
+
}, []);
|
|
814
|
+
const versioningWarnedRef = useRef(false);
|
|
815
|
+
useEffect(() => {
|
|
816
|
+
if (allowVersioning && !workspaceContainer && !versioningWarnedRef.current) {
|
|
817
|
+
console.warn(
|
|
818
|
+
"[squisq-editor] allowVersioning requires a `workspaceContainer` prop; versioning is disabled."
|
|
819
|
+
);
|
|
820
|
+
versioningWarnedRef.current = true;
|
|
821
|
+
}
|
|
822
|
+
}, [allowVersioning, workspaceContainer]);
|
|
823
|
+
const versioning = useMemo2(() => {
|
|
824
|
+
if (!allowVersioning || !workspaceContainer) return null;
|
|
825
|
+
return new DocumentVersionManager(workspaceContainer, { basename: versionBasename });
|
|
826
|
+
}, [allowVersioning, workspaceContainer, versionBasename]);
|
|
827
|
+
const onSaveVersionRef = useRef(onSaveVersion);
|
|
828
|
+
onSaveVersionRef.current = onSaveVersion;
|
|
829
|
+
const prunePolicyRef = useRef(versioningPrunePolicy);
|
|
830
|
+
prunePolicyRef.current = versioningPrunePolicy;
|
|
831
|
+
const saveVersion = useCallback2(
|
|
832
|
+
async (options) => {
|
|
833
|
+
if (!versioning) {
|
|
834
|
+
const skipped = { saved: false, version: null, reason: "no-document" };
|
|
835
|
+
onSaveVersionRef.current?.(skipped);
|
|
836
|
+
return skipped;
|
|
837
|
+
}
|
|
838
|
+
const result = await versioning.saveVersion(options);
|
|
839
|
+
onSaveVersionRef.current?.(result);
|
|
840
|
+
if (result.saved) {
|
|
841
|
+
versioning.pruneVersions(prunePolicyRef.current).catch((err) => {
|
|
842
|
+
console.warn(
|
|
843
|
+
"[squisq-editor] pruneVersions failed:",
|
|
844
|
+
err instanceof Error ? err.message : err
|
|
845
|
+
);
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
},
|
|
850
|
+
[versioning]
|
|
851
|
+
);
|
|
852
|
+
const autoSaveSourceRef = useRef(null);
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
if (!versioning) return;
|
|
855
|
+
if (versioningAutoSaveIdleMs <= 0) return;
|
|
856
|
+
const previous = autoSaveSourceRef.current;
|
|
857
|
+
autoSaveSourceRef.current = markdownSource;
|
|
858
|
+
if (previous !== null && previous === markdownSource) return;
|
|
859
|
+
const timer = setTimeout(() => {
|
|
860
|
+
saveVersion({ content: markdownSource }).catch((err) => {
|
|
861
|
+
console.warn(
|
|
862
|
+
"[squisq-editor] auto-save version failed:",
|
|
863
|
+
err instanceof Error ? err.message : err
|
|
864
|
+
);
|
|
865
|
+
});
|
|
866
|
+
}, versioningAutoSaveIdleMs);
|
|
867
|
+
return () => clearTimeout(timer);
|
|
868
|
+
}, [markdownSource, versioning, versioningAutoSaveIdleMs, saveVersion]);
|
|
869
|
+
const setMarkdownDoc = useCallback2((newDoc) => {
|
|
870
|
+
setMarkdownDocState(newDoc);
|
|
871
|
+
try {
|
|
872
|
+
const newSource = stringifyMarkdown(newDoc);
|
|
873
|
+
setMarkdownSourceRaw(newSource);
|
|
874
|
+
setParseError(null);
|
|
875
|
+
try {
|
|
876
|
+
const generatedDoc = markdownToDoc(newDoc, {
|
|
877
|
+
articleId: articleIdRef.current,
|
|
878
|
+
fileName: fileNameRef.current
|
|
879
|
+
});
|
|
880
|
+
setDoc(generatedDoc);
|
|
881
|
+
} catch (docErr) {
|
|
882
|
+
setDoc(null);
|
|
883
|
+
console.warn("Doc generation failed:", docErr instanceof Error ? docErr.message : docErr);
|
|
884
|
+
}
|
|
885
|
+
} catch (err) {
|
|
886
|
+
setParseError(err instanceof Error ? err.message : "Stringify error");
|
|
887
|
+
}
|
|
888
|
+
}, []);
|
|
889
|
+
const value = useMemo2(
|
|
890
|
+
() => ({
|
|
891
|
+
markdownSource,
|
|
892
|
+
markdownDoc,
|
|
893
|
+
doc,
|
|
894
|
+
activeView,
|
|
895
|
+
parseError,
|
|
896
|
+
isParsing,
|
|
897
|
+
colorScheme,
|
|
898
|
+
editorMode,
|
|
899
|
+
findMode,
|
|
900
|
+
language: resolvedLanguage,
|
|
901
|
+
inlinePreviewVisible,
|
|
902
|
+
statusBarVisible,
|
|
903
|
+
outlineVisible,
|
|
904
|
+
blockTagVisibility: blockTagVisibilityState,
|
|
905
|
+
blockTagsVisible,
|
|
906
|
+
themeInheritance: themeInheritanceState,
|
|
907
|
+
layoutMode: layoutModeState,
|
|
908
|
+
editorSource,
|
|
909
|
+
blockCount,
|
|
910
|
+
activeBlockKey,
|
|
911
|
+
activeBlockStartLine,
|
|
912
|
+
selectionVersion,
|
|
913
|
+
imageEditTarget,
|
|
914
|
+
mediaRevision,
|
|
915
|
+
allowRecording,
|
|
916
|
+
allowNarrate,
|
|
917
|
+
preserveSourceWrapping,
|
|
918
|
+
tiptapEditor,
|
|
919
|
+
monacoEditor,
|
|
920
|
+
activeSceneText,
|
|
921
|
+
sceneTextChannel,
|
|
922
|
+
workspaceContainer,
|
|
923
|
+
versioning,
|
|
924
|
+
saveVersion,
|
|
925
|
+
mediaProvider,
|
|
926
|
+
imageDisplayMode,
|
|
927
|
+
mentionProvider,
|
|
928
|
+
proofing,
|
|
929
|
+
proofingDefaultEnabled,
|
|
930
|
+
proofingSpellingEnabled,
|
|
931
|
+
proofingGrammarEnabled,
|
|
932
|
+
proofingIgnoreStore,
|
|
933
|
+
articleId,
|
|
934
|
+
documentLinkProvider,
|
|
935
|
+
fenceRenderers,
|
|
936
|
+
onCopyCode,
|
|
937
|
+
linkSchemes,
|
|
938
|
+
fileName,
|
|
939
|
+
saveCoverImageOutput,
|
|
940
|
+
saveDashboardImageOutput,
|
|
941
|
+
setMarkdownSource,
|
|
942
|
+
setEditorSource,
|
|
943
|
+
setLayoutMode,
|
|
944
|
+
goToBlock,
|
|
945
|
+
goToBlockByLine,
|
|
946
|
+
prevBlock,
|
|
947
|
+
nextBlock,
|
|
948
|
+
addBlock,
|
|
949
|
+
setMarkdownDoc,
|
|
950
|
+
setActiveView,
|
|
951
|
+
setFindMode,
|
|
952
|
+
setTiptapEditor,
|
|
953
|
+
setMonacoEditor,
|
|
954
|
+
setColorScheme,
|
|
955
|
+
setInlinePreviewVisible,
|
|
956
|
+
setStatusBarVisible,
|
|
957
|
+
setOutlineVisible,
|
|
958
|
+
setBlockTagVisibility,
|
|
959
|
+
setBlockTagsVisible,
|
|
960
|
+
setThemeInheritance,
|
|
961
|
+
getSelection,
|
|
962
|
+
replaceSelection,
|
|
963
|
+
getBlockAtCursor,
|
|
964
|
+
insertAtCursor,
|
|
965
|
+
replaceAll,
|
|
966
|
+
openImageEdit,
|
|
967
|
+
closeImageEdit,
|
|
968
|
+
bumpMediaRevision
|
|
969
|
+
}),
|
|
970
|
+
[
|
|
971
|
+
markdownSource,
|
|
972
|
+
markdownDoc,
|
|
973
|
+
doc,
|
|
974
|
+
activeView,
|
|
975
|
+
parseError,
|
|
976
|
+
isParsing,
|
|
977
|
+
colorScheme,
|
|
978
|
+
editorMode,
|
|
979
|
+
findMode,
|
|
980
|
+
resolvedLanguage,
|
|
981
|
+
inlinePreviewVisible,
|
|
982
|
+
statusBarVisible,
|
|
983
|
+
outlineVisible,
|
|
984
|
+
blockTagVisibilityState,
|
|
985
|
+
blockTagsVisible,
|
|
986
|
+
themeInheritanceState,
|
|
987
|
+
layoutModeState,
|
|
988
|
+
editorSource,
|
|
989
|
+
blockCount,
|
|
990
|
+
activeBlockKey,
|
|
991
|
+
activeBlockStartLine,
|
|
992
|
+
selectionVersion,
|
|
993
|
+
tiptapEditor,
|
|
994
|
+
monacoEditor,
|
|
995
|
+
activeSceneText,
|
|
996
|
+
sceneTextChannel,
|
|
997
|
+
workspaceContainer,
|
|
998
|
+
versioning,
|
|
999
|
+
saveVersion,
|
|
1000
|
+
mediaProvider,
|
|
1001
|
+
imageDisplayMode,
|
|
1002
|
+
mentionProvider,
|
|
1003
|
+
proofing,
|
|
1004
|
+
proofingDefaultEnabled,
|
|
1005
|
+
proofingSpellingEnabled,
|
|
1006
|
+
proofingGrammarEnabled,
|
|
1007
|
+
proofingIgnoreStore,
|
|
1008
|
+
articleId,
|
|
1009
|
+
documentLinkProvider,
|
|
1010
|
+
fenceRenderers,
|
|
1011
|
+
onCopyCode,
|
|
1012
|
+
linkSchemes,
|
|
1013
|
+
fileName,
|
|
1014
|
+
saveCoverImageOutput,
|
|
1015
|
+
saveDashboardImageOutput,
|
|
1016
|
+
setMarkdownSource,
|
|
1017
|
+
setEditorSource,
|
|
1018
|
+
setLayoutMode,
|
|
1019
|
+
goToBlock,
|
|
1020
|
+
goToBlockByLine,
|
|
1021
|
+
prevBlock,
|
|
1022
|
+
nextBlock,
|
|
1023
|
+
addBlock,
|
|
1024
|
+
setMarkdownDoc,
|
|
1025
|
+
setActiveView,
|
|
1026
|
+
setFindMode,
|
|
1027
|
+
setTiptapEditor,
|
|
1028
|
+
setMonacoEditor,
|
|
1029
|
+
setColorScheme,
|
|
1030
|
+
setInlinePreviewVisible,
|
|
1031
|
+
setStatusBarVisible,
|
|
1032
|
+
setOutlineVisible,
|
|
1033
|
+
setBlockTagVisibility,
|
|
1034
|
+
setBlockTagsVisible,
|
|
1035
|
+
setThemeInheritance,
|
|
1036
|
+
getSelection,
|
|
1037
|
+
replaceSelection,
|
|
1038
|
+
getBlockAtCursor,
|
|
1039
|
+
insertAtCursor,
|
|
1040
|
+
replaceAll,
|
|
1041
|
+
imageEditTarget,
|
|
1042
|
+
mediaRevision,
|
|
1043
|
+
allowRecording,
|
|
1044
|
+
allowNarrate,
|
|
1045
|
+
preserveSourceWrapping,
|
|
1046
|
+
openImageEdit,
|
|
1047
|
+
closeImageEdit,
|
|
1048
|
+
bumpMediaRevision
|
|
1049
|
+
]
|
|
1050
|
+
);
|
|
1051
|
+
return /* @__PURE__ */ jsx(EditorContext.Provider, { value, children });
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/review/decorations.ts
|
|
1055
|
+
function reviewUnderlineClass(hue) {
|
|
1056
|
+
return `squisq-review-underline squisq-review-underline--${hue}`;
|
|
1057
|
+
}
|
|
1058
|
+
function findUniqueTextRange(doc, text) {
|
|
1059
|
+
if (text.length === 0) return null;
|
|
1060
|
+
const matches = [];
|
|
1061
|
+
doc.descendants((node, pos) => {
|
|
1062
|
+
if (!node.isText || typeof node.text !== "string") return true;
|
|
1063
|
+
let index = node.text.indexOf(text);
|
|
1064
|
+
while (index !== -1) {
|
|
1065
|
+
matches.push({ from: pos + index, to: pos + index + text.length });
|
|
1066
|
+
if (matches.length > 1) return false;
|
|
1067
|
+
index = node.text.indexOf(text, index + 1);
|
|
1068
|
+
}
|
|
1069
|
+
return true;
|
|
1070
|
+
});
|
|
1071
|
+
return matches.length === 1 ? matches[0] ?? null : null;
|
|
1072
|
+
}
|
|
1073
|
+
function buildWriteViewDecorations(doc, findings, hueFor) {
|
|
1074
|
+
const specs = [];
|
|
1075
|
+
for (const finding of findings) {
|
|
1076
|
+
const range = findUniqueTextRange(doc, finding.originalText);
|
|
1077
|
+
if (!range) continue;
|
|
1078
|
+
specs.push({
|
|
1079
|
+
findingId: finding.id,
|
|
1080
|
+
source: finding.source,
|
|
1081
|
+
hue: hueFor(finding.source),
|
|
1082
|
+
from: range.from,
|
|
1083
|
+
to: range.to
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
return specs;
|
|
1087
|
+
}
|
|
1088
|
+
function buildSourceViewDecorations(model, findings, hueFor) {
|
|
1089
|
+
const total = model.getValueLength();
|
|
1090
|
+
const decorations = [];
|
|
1091
|
+
for (const finding of findings) {
|
|
1092
|
+
if (finding.start >= finding.end || finding.end > total) continue;
|
|
1093
|
+
const start = model.getPositionAt(finding.start);
|
|
1094
|
+
const end = model.getPositionAt(finding.end);
|
|
1095
|
+
decorations.push({
|
|
1096
|
+
range: {
|
|
1097
|
+
startLineNumber: start.lineNumber,
|
|
1098
|
+
startColumn: start.column,
|
|
1099
|
+
endLineNumber: end.lineNumber,
|
|
1100
|
+
endColumn: end.column
|
|
1101
|
+
},
|
|
1102
|
+
options: {
|
|
1103
|
+
inlineClassName: reviewUnderlineClass(hueFor(finding.source)),
|
|
1104
|
+
hoverMessage: { value: reviewHoverMarkdown(finding) },
|
|
1105
|
+
// NeverGrowsWhenTypingAtEdges: typing at a mark's edge belongs to the
|
|
1106
|
+
// surrounding text, not to the finding.
|
|
1107
|
+
stickiness: 1
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return decorations;
|
|
1112
|
+
}
|
|
1113
|
+
function reviewHoverMarkdown(finding) {
|
|
1114
|
+
const lines = [finding.message];
|
|
1115
|
+
if (finding.rationale) lines.push("", finding.rationale);
|
|
1116
|
+
if (finding.suggestions.length > 0) {
|
|
1117
|
+
lines.push("", ...finding.suggestions.map((s) => `- ${s.label ?? suggestionLabel(s.kind)}`));
|
|
1118
|
+
}
|
|
1119
|
+
return lines.join("\n");
|
|
1120
|
+
}
|
|
1121
|
+
function suggestionLabel(kind) {
|
|
1122
|
+
if (kind === "remove") return "Remove";
|
|
1123
|
+
if (kind === "insertAfter") return "Insert after";
|
|
1124
|
+
if (kind === "replaceBlock") return "Rewrite this block";
|
|
1125
|
+
return "Replace";
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/review/ReviewExtension.ts
|
|
1129
|
+
import { Extension } from "@tiptap/core";
|
|
1130
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
1131
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
|
1132
|
+
var REVIEW_KEY = new PluginKey("squisq-review");
|
|
1133
|
+
function buildSet(doc, specs) {
|
|
1134
|
+
const max = doc.content.size;
|
|
1135
|
+
const decorations = [];
|
|
1136
|
+
for (const spec of specs) {
|
|
1137
|
+
if (spec.from >= spec.to || spec.to > max) continue;
|
|
1138
|
+
decorations.push(
|
|
1139
|
+
Decoration.inline(
|
|
1140
|
+
spec.from,
|
|
1141
|
+
spec.to,
|
|
1142
|
+
{
|
|
1143
|
+
class: reviewUnderlineClass(spec.hue),
|
|
1144
|
+
"data-review-id": spec.findingId,
|
|
1145
|
+
"data-review-source": spec.source
|
|
1146
|
+
},
|
|
1147
|
+
{ findingId: spec.findingId, source: spec.source, hue: spec.hue }
|
|
1148
|
+
)
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return DecorationSet.create(doc, decorations);
|
|
1152
|
+
}
|
|
1153
|
+
var ReviewExtension = Extension.create({
|
|
1154
|
+
name: "squisqReview",
|
|
1155
|
+
addProseMirrorPlugins() {
|
|
1156
|
+
return [
|
|
1157
|
+
new Plugin({
|
|
1158
|
+
key: REVIEW_KEY,
|
|
1159
|
+
state: {
|
|
1160
|
+
init: () => ({ decorations: DecorationSet.empty }),
|
|
1161
|
+
apply: (transaction, previous) => {
|
|
1162
|
+
const meta = transaction.getMeta(REVIEW_KEY);
|
|
1163
|
+
if (meta) return { decorations: buildSet(transaction.doc, meta.specs) };
|
|
1164
|
+
if (transaction.docChanged) {
|
|
1165
|
+
return {
|
|
1166
|
+
decorations: previous.decorations.map(transaction.mapping, transaction.doc)
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
return previous;
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
props: {
|
|
1173
|
+
decorations: (state) => REVIEW_KEY.getState(state)?.decorations ?? null
|
|
1174
|
+
}
|
|
1175
|
+
})
|
|
1176
|
+
];
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
function updateReviewDecorations(editor, specs) {
|
|
1180
|
+
editor.view.dispatch(editor.state.tr.setMeta(REVIEW_KEY, { specs }));
|
|
1181
|
+
}
|
|
1182
|
+
function clearReviewDecorations(editor) {
|
|
1183
|
+
updateReviewDecorations(editor, []);
|
|
1184
|
+
}
|
|
1185
|
+
function resolve(hit) {
|
|
1186
|
+
if (!hit) return null;
|
|
1187
|
+
const spec = hit.spec;
|
|
1188
|
+
return {
|
|
1189
|
+
findingId: spec.findingId,
|
|
1190
|
+
source: spec.source,
|
|
1191
|
+
hue: spec.hue,
|
|
1192
|
+
from: hit.from,
|
|
1193
|
+
to: hit.to
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
function reviewDecorationAt(state, pos) {
|
|
1197
|
+
const set = REVIEW_KEY.getState(state)?.decorations;
|
|
1198
|
+
return set ? resolve(set.find(pos, pos)[0]) : null;
|
|
1199
|
+
}
|
|
1200
|
+
function reviewDecorationById(state, findingId) {
|
|
1201
|
+
const set = REVIEW_KEY.getState(state)?.decorations;
|
|
1202
|
+
if (!set) return null;
|
|
1203
|
+
return resolve(
|
|
1204
|
+
set.find(
|
|
1205
|
+
void 0,
|
|
1206
|
+
void 0,
|
|
1207
|
+
(spec) => spec.findingId === findingId
|
|
1208
|
+
)[0]
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/review/buildRequest.ts
|
|
1213
|
+
import { blankProtectedSpans } from "@bendyline/squisq/proof";
|
|
1214
|
+
var HEADING = /^\s{0,3}#{1,6}\s+(.+)$/;
|
|
1215
|
+
function buildReviewRequest(options) {
|
|
1216
|
+
const { source, documentRef } = options;
|
|
1217
|
+
const masked = blankProtectedSpans(source, { markdownCode: true }).text;
|
|
1218
|
+
const slices = getBlockSlices(source);
|
|
1219
|
+
const blocks = [];
|
|
1220
|
+
let heading = null;
|
|
1221
|
+
let line = 1;
|
|
1222
|
+
let consumed = 0;
|
|
1223
|
+
for (const [key, slice] of slices.entries()) {
|
|
1224
|
+
line += countNewlines(source.slice(consumed, slice.range.startOffset));
|
|
1225
|
+
consumed = slice.range.startOffset;
|
|
1226
|
+
const firstLine = slice.text.slice(0, indexOfNewline(slice.text));
|
|
1227
|
+
const headingMatch = HEADING.exec(firstLine.trimEnd());
|
|
1228
|
+
if (headingMatch) heading = headingMatch[1]?.trim() || null;
|
|
1229
|
+
const text = masked.slice(slice.range.startOffset, slice.range.endOffset);
|
|
1230
|
+
if (text.trim().length === 0) continue;
|
|
1231
|
+
blocks.push({
|
|
1232
|
+
key,
|
|
1233
|
+
startLine: line,
|
|
1234
|
+
heading,
|
|
1235
|
+
text,
|
|
1236
|
+
offset: slice.range.startOffset
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
return {
|
|
1240
|
+
source,
|
|
1241
|
+
blocks,
|
|
1242
|
+
documentRef,
|
|
1243
|
+
...options.changedBlockKeys ? { changedBlockKeys: options.changedBlockKeys } : {},
|
|
1244
|
+
...options.language ? { language: options.language } : {}
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function changedBlockKeys(previous, next) {
|
|
1248
|
+
const before = new Map(previous.map((block) => [block.key, block.text]));
|
|
1249
|
+
const changed = [];
|
|
1250
|
+
for (const block of next) {
|
|
1251
|
+
if (before.get(block.key) !== block.text) changed.push(block.key);
|
|
1252
|
+
}
|
|
1253
|
+
return changed;
|
|
1254
|
+
}
|
|
1255
|
+
function indexOfNewline(text) {
|
|
1256
|
+
const index = text.indexOf("\n");
|
|
1257
|
+
return index === -1 ? text.length : index;
|
|
1258
|
+
}
|
|
1259
|
+
function countNewlines(text) {
|
|
1260
|
+
let count = 0;
|
|
1261
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1262
|
+
if (text[i] === "\n") count += 1;
|
|
1263
|
+
}
|
|
1264
|
+
return count;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// src/review/types.ts
|
|
1268
|
+
function resolveReviewProvider(capability) {
|
|
1269
|
+
return typeof capability === "function" ? capability() : capability;
|
|
1270
|
+
}
|
|
1271
|
+
var DEFAULT_REVIEW_DEBOUNCE_MS = 2e3;
|
|
1272
|
+
var DEFAULT_REVIEW_HUE = "style";
|
|
1273
|
+
|
|
1274
|
+
// src/review/runner.ts
|
|
1275
|
+
var ReviewRunner = class {
|
|
1276
|
+
constructor(options) {
|
|
1277
|
+
this.disposed = false;
|
|
1278
|
+
this.options = options;
|
|
1279
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
1280
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1281
|
+
this.runtimes = options.providers.map((provider) => ({
|
|
1282
|
+
provider,
|
|
1283
|
+
status: {
|
|
1284
|
+
id: provider.id,
|
|
1285
|
+
label: provider.label,
|
|
1286
|
+
phase: "idle",
|
|
1287
|
+
findings: [],
|
|
1288
|
+
error: null
|
|
1289
|
+
},
|
|
1290
|
+
timer: null,
|
|
1291
|
+
controller: null,
|
|
1292
|
+
dirty: false,
|
|
1293
|
+
setupDone: false
|
|
1294
|
+
}));
|
|
1295
|
+
}
|
|
1296
|
+
/** The document changed. Reschedule every provider. */
|
|
1297
|
+
schedule() {
|
|
1298
|
+
if (this.disposed) return;
|
|
1299
|
+
for (const runtime of this.runtimes) this.scheduleOne(runtime);
|
|
1300
|
+
}
|
|
1301
|
+
/** Run now, skipping the debounce — the user asked for a review. */
|
|
1302
|
+
runNow() {
|
|
1303
|
+
if (this.disposed) return;
|
|
1304
|
+
for (const runtime of this.runtimes) {
|
|
1305
|
+
if (runtime.timer !== null) {
|
|
1306
|
+
this.clearTimer(runtime.timer);
|
|
1307
|
+
runtime.timer = null;
|
|
1308
|
+
}
|
|
1309
|
+
void this.runPass(runtime);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
snapshot() {
|
|
1313
|
+
const providers = this.runtimes.map((runtime) => runtime.status);
|
|
1314
|
+
return {
|
|
1315
|
+
findings: providers.flatMap((status) => status.findings),
|
|
1316
|
+
providers,
|
|
1317
|
+
running: providers.some(
|
|
1318
|
+
(status) => status.phase === "running" || status.phase === "preparing"
|
|
1319
|
+
)
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
/** Drop a finding from the visible set without re-running anything. */
|
|
1323
|
+
removeFinding(findingId) {
|
|
1324
|
+
for (const runtime of this.runtimes) {
|
|
1325
|
+
const next = runtime.status.findings.filter((finding) => finding.id !== findingId);
|
|
1326
|
+
if (next.length !== runtime.status.findings.length) {
|
|
1327
|
+
runtime.status = { ...runtime.status, findings: next };
|
|
1328
|
+
this.emit();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
dispose() {
|
|
1334
|
+
this.disposed = true;
|
|
1335
|
+
for (const runtime of this.runtimes) {
|
|
1336
|
+
if (runtime.timer !== null) this.clearTimer(runtime.timer);
|
|
1337
|
+
runtime.timer = null;
|
|
1338
|
+
runtime.controller?.abort();
|
|
1339
|
+
runtime.controller = null;
|
|
1340
|
+
runtime.provider.dispose?.();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
scheduleOne(runtime) {
|
|
1344
|
+
if (runtime.controller) {
|
|
1345
|
+
runtime.dirty = true;
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (runtime.timer !== null) this.clearTimer(runtime.timer);
|
|
1349
|
+
const delay = runtime.provider.debounceMs ?? DEFAULT_REVIEW_DEBOUNCE_MS;
|
|
1350
|
+
runtime.timer = this.setTimer(() => {
|
|
1351
|
+
runtime.timer = null;
|
|
1352
|
+
void this.runPass(runtime);
|
|
1353
|
+
}, delay);
|
|
1354
|
+
}
|
|
1355
|
+
async runPass(runtime) {
|
|
1356
|
+
if (this.disposed || runtime.controller) return;
|
|
1357
|
+
const request = this.options.buildRequest();
|
|
1358
|
+
if (!request) return;
|
|
1359
|
+
const startedAt = this.options.revision();
|
|
1360
|
+
const controller = new AbortController();
|
|
1361
|
+
runtime.controller = controller;
|
|
1362
|
+
runtime.dirty = false;
|
|
1363
|
+
if (!runtime.setupDone && runtime.provider.setup) {
|
|
1364
|
+
this.setPhase(runtime, "preparing");
|
|
1365
|
+
try {
|
|
1366
|
+
await runtime.provider.setup();
|
|
1367
|
+
runtime.setupDone = true;
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
runtime.controller = null;
|
|
1370
|
+
this.fail(runtime, error);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (this.disposed) {
|
|
1374
|
+
runtime.controller = null;
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
this.setPhase(runtime, "running");
|
|
1379
|
+
const collected = [];
|
|
1380
|
+
let failure = null;
|
|
1381
|
+
try {
|
|
1382
|
+
for await (const event of runtime.provider.review(request, controller.signal)) {
|
|
1383
|
+
if (controller.signal.aborted || this.disposed) break;
|
|
1384
|
+
if (event.type === "findings") collected.push(...event.findings);
|
|
1385
|
+
else if (event.type === "error") failure = event.message;
|
|
1386
|
+
}
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
if (!controller.signal.aborted) failure = messageOf(error);
|
|
1389
|
+
}
|
|
1390
|
+
runtime.controller = null;
|
|
1391
|
+
if (this.disposed) return;
|
|
1392
|
+
const stale = this.options.revision() !== startedAt;
|
|
1393
|
+
if (!controller.signal.aborted && !stale) {
|
|
1394
|
+
runtime.status = failure ? { ...runtime.status, phase: "error", error: failure } : { ...runtime.status, phase: "ready", error: null, findings: collected };
|
|
1395
|
+
this.emit();
|
|
1396
|
+
}
|
|
1397
|
+
if (runtime.dirty || stale) {
|
|
1398
|
+
runtime.dirty = false;
|
|
1399
|
+
this.scheduleOne(runtime);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
setPhase(runtime, phase) {
|
|
1403
|
+
runtime.status = { ...runtime.status, phase };
|
|
1404
|
+
this.emit();
|
|
1405
|
+
}
|
|
1406
|
+
fail(runtime, error) {
|
|
1407
|
+
runtime.status = { ...runtime.status, phase: "error", error: messageOf(error) };
|
|
1408
|
+
this.emit();
|
|
1409
|
+
}
|
|
1410
|
+
emit() {
|
|
1411
|
+
if (!this.disposed) this.options.onChange(this.snapshot());
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
function messageOf(error) {
|
|
1415
|
+
return error instanceof Error ? error.message : String(error);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/review/useReview.ts
|
|
1419
|
+
import { useCallback as useCallback3, useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState3 } from "react";
|
|
1420
|
+
var EMPTY = [];
|
|
1421
|
+
var EMPTY_FINDINGS = [];
|
|
1422
|
+
function useReview(capabilities = EMPTY) {
|
|
1423
|
+
const {
|
|
1424
|
+
markdownSource,
|
|
1425
|
+
activeView,
|
|
1426
|
+
tiptapEditor,
|
|
1427
|
+
monacoEditor,
|
|
1428
|
+
fileName,
|
|
1429
|
+
articleId,
|
|
1430
|
+
goToBlockByLine
|
|
1431
|
+
} = useEditorContext();
|
|
1432
|
+
const providers = useMemo3(
|
|
1433
|
+
() => capabilities.map(resolveReviewProvider),
|
|
1434
|
+
[capabilities]
|
|
1435
|
+
);
|
|
1436
|
+
const [snapshot, setSnapshot] = useState3(null);
|
|
1437
|
+
const sourceRef = useRef2(markdownSource);
|
|
1438
|
+
const revisionRef = useRef2(0);
|
|
1439
|
+
const blocksRef = useRef2([]);
|
|
1440
|
+
const runnerRef = useRef2(null);
|
|
1441
|
+
sourceRef.current = markdownSource;
|
|
1442
|
+
const hueFor = useMemo3(() => {
|
|
1443
|
+
const hues = new Map(
|
|
1444
|
+
providers.map((provider) => [provider.id, provider.hue ?? DEFAULT_REVIEW_HUE])
|
|
1445
|
+
);
|
|
1446
|
+
return (source) => hues.get(source) ?? DEFAULT_REVIEW_HUE;
|
|
1447
|
+
}, [providers]);
|
|
1448
|
+
useEffect2(() => {
|
|
1449
|
+
if (providers.length === 0) {
|
|
1450
|
+
setSnapshot(null);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
const runner = new ReviewRunner({
|
|
1454
|
+
providers,
|
|
1455
|
+
revision: () => revisionRef.current,
|
|
1456
|
+
buildRequest: () => {
|
|
1457
|
+
const request = buildReviewRequest({
|
|
1458
|
+
source: sourceRef.current,
|
|
1459
|
+
documentRef: { articleId, ...fileName ? { fileName } : {} },
|
|
1460
|
+
language: "markdown"
|
|
1461
|
+
});
|
|
1462
|
+
const changed = changedBlockKeys(blocksRef.current, request.blocks);
|
|
1463
|
+
blocksRef.current = request.blocks;
|
|
1464
|
+
return { ...request, changedBlockKeys: changed };
|
|
1465
|
+
},
|
|
1466
|
+
onChange: setSnapshot
|
|
1467
|
+
});
|
|
1468
|
+
runnerRef.current = runner;
|
|
1469
|
+
setSnapshot(runner.snapshot());
|
|
1470
|
+
return () => {
|
|
1471
|
+
runner.dispose();
|
|
1472
|
+
runnerRef.current = null;
|
|
1473
|
+
};
|
|
1474
|
+
}, [providers, articleId, fileName]);
|
|
1475
|
+
useEffect2(() => {
|
|
1476
|
+
revisionRef.current += 1;
|
|
1477
|
+
runnerRef.current?.schedule();
|
|
1478
|
+
}, [markdownSource]);
|
|
1479
|
+
const findings = useMemo3(() => snapshot?.findings ?? EMPTY_FINDINGS, [snapshot]);
|
|
1480
|
+
useEffect2(() => {
|
|
1481
|
+
if (!tiptapEditor || activeView !== "wysiwyg") return;
|
|
1482
|
+
if (findings.length === 0) {
|
|
1483
|
+
clearReviewDecorations(tiptapEditor);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
updateReviewDecorations(
|
|
1487
|
+
tiptapEditor,
|
|
1488
|
+
buildWriteViewDecorations(tiptapEditor.state.doc, findings, hueFor)
|
|
1489
|
+
);
|
|
1490
|
+
}, [tiptapEditor, activeView, findings, hueFor]);
|
|
1491
|
+
const collectionRef = useRef2(null);
|
|
1492
|
+
useEffect2(() => {
|
|
1493
|
+
if (!monacoEditor || activeView !== "raw") return;
|
|
1494
|
+
const model = monacoEditor.getModel();
|
|
1495
|
+
if (!model) return;
|
|
1496
|
+
collectionRef.current ?? (collectionRef.current = monacoEditor.createDecorationsCollection([]));
|
|
1497
|
+
collectionRef.current.set(buildSourceViewDecorations(model, findings, hueFor));
|
|
1498
|
+
return () => {
|
|
1499
|
+
collectionRef.current?.clear();
|
|
1500
|
+
};
|
|
1501
|
+
}, [monacoEditor, activeView, findings, hueFor]);
|
|
1502
|
+
const goToFinding = useCallback3(
|
|
1503
|
+
(findingId) => {
|
|
1504
|
+
const finding = findings.find((candidate) => candidate.id === findingId);
|
|
1505
|
+
if (!finding) return;
|
|
1506
|
+
if (finding.blockKey !== void 0) {
|
|
1507
|
+
const block = blocksRef.current.find((candidate) => candidate.key === finding.blockKey);
|
|
1508
|
+
if (block) goToBlockByLine(block.startLine);
|
|
1509
|
+
}
|
|
1510
|
+
},
|
|
1511
|
+
[findings, goToBlockByLine]
|
|
1512
|
+
);
|
|
1513
|
+
const reviewNow = useCallback3(() => runnerRef.current?.runNow(), []);
|
|
1514
|
+
const dismissFinding = useCallback3((findingId) => {
|
|
1515
|
+
runnerRef.current?.removeFinding(findingId);
|
|
1516
|
+
}, []);
|
|
1517
|
+
return useMemo3(
|
|
1518
|
+
() => snapshot ? { ...snapshot, reviewNow, dismissFinding, goToFinding } : null,
|
|
1519
|
+
[snapshot, reviewNow, dismissFinding, goToFinding]
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// src/review/ReviewContext.tsx
|
|
1524
|
+
import { createContext as createContext2, useContext as useContext2 } from "react";
|
|
1525
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
1526
|
+
var ReviewStateContext = createContext2(null);
|
|
1527
|
+
function useReviewState() {
|
|
1528
|
+
return useContext2(ReviewStateContext);
|
|
1529
|
+
}
|
|
1530
|
+
function ReviewRoot({
|
|
1531
|
+
providers,
|
|
1532
|
+
children
|
|
1533
|
+
}) {
|
|
1534
|
+
const state = useReview(providers);
|
|
1535
|
+
return /* @__PURE__ */ jsx2(ReviewStateContext.Provider, { value: state, children });
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// src/review/ReviewPanel.tsx
|
|
1539
|
+
import { jsx as jsx3, jsxs } from "react/jsx-runtime";
|
|
1540
|
+
function ReviewPanel() {
|
|
1541
|
+
const state = useReviewState();
|
|
1542
|
+
if (!state || state.providers.length === 0) return null;
|
|
1543
|
+
const total = state.findings.length;
|
|
1544
|
+
return /* @__PURE__ */ jsxs("aside", { className: "squisq-review-panel", "aria-label": "Review findings", children: [
|
|
1545
|
+
/* @__PURE__ */ jsxs("div", { className: "squisq-review-panel-header", children: [
|
|
1546
|
+
/* @__PURE__ */ jsxs("span", { className: "squisq-review-panel-title", children: [
|
|
1547
|
+
"Review",
|
|
1548
|
+
/* @__PURE__ */ jsx3("span", { className: "squisq-review-panel-count", children: total })
|
|
1549
|
+
] }),
|
|
1550
|
+
/* @__PURE__ */ jsx3(
|
|
1551
|
+
"button",
|
|
1552
|
+
{
|
|
1553
|
+
type: "button",
|
|
1554
|
+
className: "squisq-review-panel-button",
|
|
1555
|
+
onClick: state.reviewNow,
|
|
1556
|
+
disabled: state.running,
|
|
1557
|
+
children: state.running ? "Reviewing\u2026" : "Review now"
|
|
1558
|
+
}
|
|
1559
|
+
)
|
|
1560
|
+
] }),
|
|
1561
|
+
state.providers.map((provider) => /* @__PURE__ */ jsx3(ProviderGroup, { provider, state }, provider.id))
|
|
1562
|
+
] });
|
|
1563
|
+
}
|
|
1564
|
+
function ProviderGroup({
|
|
1565
|
+
provider,
|
|
1566
|
+
state
|
|
1567
|
+
}) {
|
|
1568
|
+
return /* @__PURE__ */ jsxs("section", { className: "squisq-review-panel-group", children: [
|
|
1569
|
+
/* @__PURE__ */ jsx3("h3", { className: "squisq-review-panel-group-title", children: provider.label }),
|
|
1570
|
+
provider.phase === "preparing" && /* @__PURE__ */ jsx3("div", { className: "squisq-review-panel-empty", children: "Preparing\u2026" }),
|
|
1571
|
+
provider.phase === "running" && /* @__PURE__ */ jsx3("div", { className: "squisq-review-panel-empty", children: "Reviewing\u2026" }),
|
|
1572
|
+
provider.phase === "error" && // The provider's own message, verbatim: a reviewer that cannot reach
|
|
1573
|
+
// its model and one whose engine is missing need different fixes.
|
|
1574
|
+
/* @__PURE__ */ jsx3("div", { className: "squisq-review-panel-empty", role: "status", children: provider.error ?? "Review failed." }),
|
|
1575
|
+
provider.phase === "ready" && provider.findings.length === 0 && /* @__PURE__ */ jsx3("div", { className: "squisq-review-panel-empty", children: "Nothing to report." }),
|
|
1576
|
+
/* @__PURE__ */ jsx3("ul", { className: "squisq-review-panel-list", children: provider.findings.map((finding) => /* @__PURE__ */ jsx3("li", { children: /* @__PURE__ */ jsx3(Row, { finding, onGo: state.goToFinding, onDismiss: state.dismissFinding }) }, finding.id)) })
|
|
1577
|
+
] });
|
|
1578
|
+
}
|
|
1579
|
+
function Row({
|
|
1580
|
+
finding,
|
|
1581
|
+
onGo,
|
|
1582
|
+
onDismiss
|
|
1583
|
+
}) {
|
|
1584
|
+
return /* @__PURE__ */ jsxs("div", { className: "squisq-review-panel-row", children: [
|
|
1585
|
+
/* @__PURE__ */ jsxs(
|
|
1586
|
+
"button",
|
|
1587
|
+
{
|
|
1588
|
+
type: "button",
|
|
1589
|
+
className: "squisq-review-panel-row-main",
|
|
1590
|
+
onClick: () => onGo(finding.id),
|
|
1591
|
+
children: [
|
|
1592
|
+
/* @__PURE__ */ jsx3(
|
|
1593
|
+
"span",
|
|
1594
|
+
{
|
|
1595
|
+
className: `squisq-review-dot squisq-review-dot--${finding.severity}`,
|
|
1596
|
+
"aria-hidden": true
|
|
1597
|
+
}
|
|
1598
|
+
),
|
|
1599
|
+
/* @__PURE__ */ jsxs("span", { className: "squisq-review-panel-row-body", children: [
|
|
1600
|
+
/* @__PURE__ */ jsx3("span", { className: "squisq-review-panel-excerpt", children: finding.originalText }),
|
|
1601
|
+
/* @__PURE__ */ jsx3("span", { className: "squisq-review-panel-message", children: finding.message }),
|
|
1602
|
+
finding.rationale && /* @__PURE__ */ jsx3("span", { className: "squisq-review-panel-rationale", children: finding.rationale })
|
|
1603
|
+
] })
|
|
1604
|
+
]
|
|
1605
|
+
}
|
|
1606
|
+
),
|
|
1607
|
+
/* @__PURE__ */ jsx3(
|
|
1608
|
+
"button",
|
|
1609
|
+
{
|
|
1610
|
+
type: "button",
|
|
1611
|
+
className: "squisq-review-panel-button",
|
|
1612
|
+
title: "Dismiss",
|
|
1613
|
+
"aria-label": `Dismiss: ${finding.message}`,
|
|
1614
|
+
onClick: () => onDismiss(finding.id),
|
|
1615
|
+
children: "\xD7"
|
|
1616
|
+
}
|
|
1617
|
+
)
|
|
1618
|
+
] });
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
export {
|
|
1622
|
+
stripFrontmatter,
|
|
1623
|
+
getBlockSlices,
|
|
1624
|
+
spliceBlock,
|
|
1625
|
+
lineToOffset,
|
|
1626
|
+
offsetToLine,
|
|
1627
|
+
sliceIndexAtOffset,
|
|
1628
|
+
detectLanguageFromFileName,
|
|
1629
|
+
resolveFileKind,
|
|
1630
|
+
useBlockNavigator,
|
|
1631
|
+
useEditorContext,
|
|
1632
|
+
EditorProvider,
|
|
1633
|
+
reviewUnderlineClass,
|
|
1634
|
+
findUniqueTextRange,
|
|
1635
|
+
buildWriteViewDecorations,
|
|
1636
|
+
buildSourceViewDecorations,
|
|
1637
|
+
reviewHoverMarkdown,
|
|
1638
|
+
ReviewExtension,
|
|
1639
|
+
updateReviewDecorations,
|
|
1640
|
+
clearReviewDecorations,
|
|
1641
|
+
reviewDecorationAt,
|
|
1642
|
+
reviewDecorationById,
|
|
1643
|
+
buildReviewRequest,
|
|
1644
|
+
changedBlockKeys,
|
|
1645
|
+
resolveReviewProvider,
|
|
1646
|
+
DEFAULT_REVIEW_DEBOUNCE_MS,
|
|
1647
|
+
DEFAULT_REVIEW_HUE,
|
|
1648
|
+
ReviewRunner,
|
|
1649
|
+
useReview,
|
|
1650
|
+
useReviewState,
|
|
1651
|
+
ReviewRoot,
|
|
1652
|
+
ReviewPanel
|
|
1653
|
+
};
|