@sutrata/editor 0.2.0 → 0.3.0
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/context/DocumentContext.d.ts +6 -1
- package/dist/context/DocumentContext.js +88 -17
- package/dist/editor/EditorView.js +4 -2
- package/dist/editor/editor-bus.d.ts +3 -0
- package/dist/editor/editor-bus.js +13 -0
- package/dist/editor/external-change.d.ts +11 -0
- package/dist/editor/external-change.js +105 -0
- package/dist/editor/frontmatter-field-view.js +3 -0
- package/dist/editor/private-metadata-plugin.d.ts +7 -4
- package/dist/editor/private-metadata-plugin.js +49 -9
- package/dist/editor/schema.js +5 -1
- package/dist/extensions/session.d.ts +4 -1
- package/dist/extensions/storage-adapter.d.ts +36 -1
- package/dist/index.d.ts +1 -1
- package/dist/shell/AppBar.js +6 -4
- package/dist/source/SourceView.js +22 -3
- package/dist/styles/screenplay.css +2 -1
- package/dist/titlepage/frontmatter-form.js +1 -1
- package/package.json +2 -2
|
@@ -19,6 +19,8 @@ interface DocumentContextValue {
|
|
|
19
19
|
ast: DocumentNode;
|
|
20
20
|
mode: EditorMode;
|
|
21
21
|
filePath: string | null;
|
|
22
|
+
/** What to call the open document: StorageAdapter.displayName, else filePath. */
|
|
23
|
+
fileDisplayName: string | null;
|
|
22
24
|
isDirty: boolean;
|
|
23
25
|
lastSaveTarget: 'file' | 'local' | null;
|
|
24
26
|
ribbonVisible: boolean;
|
|
@@ -37,7 +39,10 @@ interface DocumentContextValue {
|
|
|
37
39
|
toasts: ToastItem[];
|
|
38
40
|
versions: VersionEntry[];
|
|
39
41
|
versionHistoryVisible: boolean;
|
|
40
|
-
|
|
42
|
+
/** `markDirty: false` for changes that are not user edits (e.g. applied from storage). */
|
|
43
|
+
setText: (next: string, options?: {
|
|
44
|
+
markDirty?: boolean;
|
|
45
|
+
}) => void;
|
|
41
46
|
setMode: (mode: EditorMode) => void;
|
|
42
47
|
setFilePath: (path: string | null) => void;
|
|
43
48
|
setRibbonVisible: (v: boolean) => void;
|
|
@@ -4,7 +4,7 @@ import { parse, importFountain, fountainDocToSutra } from '@sutrata/parser';
|
|
|
4
4
|
import { StorageAdapterProvider } from '../extensions/storage-adapter';
|
|
5
5
|
import { AIProviderProvider } from '../extensions/ai-provider';
|
|
6
6
|
import { SpeechProviderProvider } from '../extensions/speech-provider';
|
|
7
|
-
import { SessionProvider, DEFAULT_SESSION } from '../extensions/session';
|
|
7
|
+
import { SessionProvider, DEFAULT_SESSION, isFeatureEnabled } from '../extensions/session';
|
|
8
8
|
import { PanelRegistryProvider, createPanelRegistry } from '../extensions/panel-registry';
|
|
9
9
|
import { CommandRegistryProvider, createCommandRegistry, matchesShortcut } from '../extensions/command-registry';
|
|
10
10
|
import { ExportRegistryProvider, createExportRegistry } from '../extensions/export-registry';
|
|
@@ -12,7 +12,10 @@ import { withBuiltins } from '../extensions/registry-store';
|
|
|
12
12
|
import { DecorationProvidersProvider } from '../extensions/decorations';
|
|
13
13
|
import { CollabBindingProvider } from '../extensions/collab';
|
|
14
14
|
import { importDocx as importDocxToSutra } from '../file/docx-importer';
|
|
15
|
-
import { getEditorView, getSourceView } from '../editor/editor-bus';
|
|
15
|
+
import { getEditorView, getSourceView, markExternalText } from '../editor/editor-bus';
|
|
16
|
+
import { externalChange } from '../editor/external-change';
|
|
17
|
+
import { sutraToProsemirror } from '../editor/sutra-to-prosemirror';
|
|
18
|
+
import { prosemirrorToSutra } from '../editor/prosemirror-to-sutra';
|
|
16
19
|
import { setFlatFrontmatterField } from '../editor/frontmatter-field';
|
|
17
20
|
import { builtinCommands } from '../shell/builtin-commands';
|
|
18
21
|
import { resolveStyle } from '../styles/registry';
|
|
@@ -69,7 +72,18 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
69
72
|
const [text, setTextState] = useState(EMPTY_DOC);
|
|
70
73
|
const [ast, setAst] = useState(() => parse(EMPTY_DOC));
|
|
71
74
|
const [mode, setMode] = useState('formatted');
|
|
72
|
-
const [filePath,
|
|
75
|
+
const [filePath, setFilePathState] = useState(null);
|
|
76
|
+
// Updated at once (like textRef), so a save right after setFilePath, even
|
|
77
|
+
// from a child's effect in the same commit, goes to the new path.
|
|
78
|
+
const filePathRef = useRef(filePath);
|
|
79
|
+
// Mirrored in render, not in an effect: a parent's effect runs after its
|
|
80
|
+
// children's, and would overwrite a newer path set by a child's effect.
|
|
81
|
+
filePathRef.current = filePath;
|
|
82
|
+
const setFilePath = useCallback((path) => {
|
|
83
|
+
filePathRef.current = path;
|
|
84
|
+
setFilePathState(path);
|
|
85
|
+
}, []);
|
|
86
|
+
const fileDisplayName = filePath ? storageAdapter.displayName?.(filePath) ?? filePath : null;
|
|
73
87
|
const [isDirty, setIsDirty] = useState(false);
|
|
74
88
|
// Where the most recent save attempt went. isDirty tracks "does this differ
|
|
75
89
|
// from the real file on disk" (VS Code-style) — a local autosave backs the
|
|
@@ -128,10 +142,15 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
128
142
|
}
|
|
129
143
|
}, [storageAdapter]);
|
|
130
144
|
useEffect(() => { void reloadCustomStyles(); }, [reloadCustomStyles]);
|
|
145
|
+
// `localFiles: false` (SessionContext): the embedder's documents are not local
|
|
146
|
+
// files, so there is no file to open or save as, and no local-session recovery
|
|
147
|
+
// (the embedder opens its documents and keeps its own offline copy).
|
|
148
|
+
const localFilesRef = useRef(true);
|
|
149
|
+
localFilesRef.current = isFeatureEnabled(session ?? DEFAULT_SESSION, 'localFiles');
|
|
131
150
|
// Recover autosaved-but-unsaved content on boot (Ctrl-R, a crash, or the tab closing
|
|
132
151
|
// without an explicit save) instead of always starting from a blank document.
|
|
133
152
|
useEffect(() => {
|
|
134
|
-
const pointer = readRecoveryPointer();
|
|
153
|
+
const pointer = localFilesRef.current ? readRecoveryPointer() : null;
|
|
135
154
|
if (!pointer)
|
|
136
155
|
return;
|
|
137
156
|
let cancelled = false;
|
|
@@ -159,10 +178,18 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
159
178
|
})();
|
|
160
179
|
return () => { cancelled = true; };
|
|
161
180
|
}, [storageAdapter, showToast]);
|
|
162
|
-
|
|
181
|
+
// Refs so the autosave path always writes the latest text/filePath without
|
|
182
|
+
// needing them in a dependency array that would tear down the timers below
|
|
183
|
+
// on every keystroke. setText also updates textRef at once, so a save that
|
|
184
|
+
// is waiting on storage can tell whether the user typed in the meantime.
|
|
185
|
+
const textRef = useRef(text);
|
|
186
|
+
textRef.current = text; // in render, for the same reason as filePathRef
|
|
187
|
+
const setText = useCallback((next, options) => {
|
|
188
|
+
textRef.current = next;
|
|
163
189
|
setTextState(next);
|
|
164
190
|
setAst(parse(next));
|
|
165
|
-
|
|
191
|
+
if (options?.markDirty !== false)
|
|
192
|
+
setDirty(true);
|
|
166
193
|
}, []);
|
|
167
194
|
const frontmatterData = ast.frontmatter?.data;
|
|
168
195
|
const documentStyleId = frontmatterData?.['style'];
|
|
@@ -173,21 +200,48 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
173
200
|
setFlatFrontmatterField(getEditorView(), text, setText, 'style', id);
|
|
174
201
|
}, [text, setText]);
|
|
175
202
|
const markClean = useCallback(() => setDirty(false), []);
|
|
176
|
-
// Refs so the autosave path always writes the latest text/filePath without
|
|
177
|
-
// needing them in a dependency array that would tear down the timers below
|
|
178
|
-
// on every keystroke.
|
|
179
|
-
const textRef = useRef(text);
|
|
180
|
-
useEffect(() => { textRef.current = text; }, [text]);
|
|
181
203
|
const modeRef = useRef(mode);
|
|
182
204
|
modeRef.current = mode;
|
|
183
|
-
const filePathRef = useRef(filePath);
|
|
184
|
-
useEffect(() => { filePathRef.current = filePath; }, [filePath]);
|
|
185
205
|
const autosaveFailedRef = useRef(false);
|
|
206
|
+
// The text last written to or received from storage, for filePath.
|
|
207
|
+
const syncedRef = useRef(null);
|
|
208
|
+
/**
|
|
209
|
+
* Applies a document from storage (a SaveResult or a remote update, OSS spec
|
|
210
|
+
* §11.5): one change, kept out of undo history, not a user edit. In the
|
|
211
|
+
* formatted editor only the blocks that differ are replaced.
|
|
212
|
+
*/
|
|
213
|
+
const applyExternalText = useCallback((path, next) => {
|
|
214
|
+
const view = getEditorView();
|
|
215
|
+
if (modeRef.current === 'formatted' && view) {
|
|
216
|
+
const tr = externalChange(view.state, sutraToProsemirror(parse(next)));
|
|
217
|
+
if (tr)
|
|
218
|
+
view.dispatch(tr);
|
|
219
|
+
// The editor writes its own serialization of `next`; that is what is synced.
|
|
220
|
+
syncedRef.current = { path, text: prosemirrorToSutra(view.state.doc) };
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
markExternalText(next);
|
|
224
|
+
setText(next, { markDirty: false });
|
|
225
|
+
syncedRef.current = { path, text: next };
|
|
226
|
+
}
|
|
227
|
+
}, [setText]);
|
|
186
228
|
const save = useCallback(async () => {
|
|
187
229
|
const path = filePathRef.current ?? '__autosave__';
|
|
230
|
+
const sent = textRef.current;
|
|
231
|
+
if (syncedRef.current?.path === path && syncedRef.current.text === sent)
|
|
232
|
+
return;
|
|
188
233
|
try {
|
|
189
|
-
await storageAdapter.saveDocument(path,
|
|
190
|
-
|
|
234
|
+
const result = await storageAdapter.saveDocument(path, sent);
|
|
235
|
+
syncedRef.current = { path, text: sent };
|
|
236
|
+
const content = result ? result.content : undefined;
|
|
237
|
+
// Only if nothing was typed while the save was in flight; otherwise the
|
|
238
|
+
// next save carries those edits and the adapter merges again.
|
|
239
|
+
const samePath = (filePathRef.current ?? '__autosave__') === path;
|
|
240
|
+
if (content !== undefined && content !== sent && textRef.current === sent && samePath) {
|
|
241
|
+
applyExternalText(path, content);
|
|
242
|
+
}
|
|
243
|
+
if (localFilesRef.current)
|
|
244
|
+
writeRecoveryPointer(path);
|
|
191
245
|
setLastSaveTarget('local');
|
|
192
246
|
autosaveFailedRef.current = false;
|
|
193
247
|
// Deliberately NOT setDirty(false) here — this is a local backup, not a
|
|
@@ -207,7 +261,20 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
207
261
|
showToast('Autosave failed — your browser storage may be full or unavailable.', 'warn');
|
|
208
262
|
}
|
|
209
263
|
}
|
|
210
|
-
}, [storageAdapter, showToast]);
|
|
264
|
+
}, [storageAdapter, showToast, applyExternalText]);
|
|
265
|
+
// Changes saved elsewhere (OSS spec §11.5), applied only while there are no
|
|
266
|
+
// unsaved local edits; otherwise the next save merges them.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!filePath || !storageAdapter.subscribe)
|
|
269
|
+
return;
|
|
270
|
+
return storageAdapter.subscribe(filePath, content => {
|
|
271
|
+
const synced = syncedRef.current;
|
|
272
|
+
if (filePathRef.current !== filePath || synced?.path !== filePath || textRef.current !== synced.text)
|
|
273
|
+
return;
|
|
274
|
+
if (content !== textRef.current)
|
|
275
|
+
applyExternalText(filePath, content);
|
|
276
|
+
});
|
|
277
|
+
}, [filePath, storageAdapter, applyExternalText]);
|
|
211
278
|
const newDocument = useCallback((initialContent) => {
|
|
212
279
|
const doNew = () => {
|
|
213
280
|
if (pauseTimerRef.current) {
|
|
@@ -242,6 +309,8 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
242
309
|
});
|
|
243
310
|
}, [isDirty, showConfirm]);
|
|
244
311
|
const openFile = useCallback(async () => {
|
|
312
|
+
if (!localFilesRef.current)
|
|
313
|
+
return;
|
|
245
314
|
const doOpen = async () => {
|
|
246
315
|
const result = await storageAdapter.openFile();
|
|
247
316
|
if (!result)
|
|
@@ -324,6 +393,8 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
324
393
|
}
|
|
325
394
|
}, [filePath, text, showToast, storageAdapter, refreshVersions]);
|
|
326
395
|
const saveFileAs = useCallback(async () => {
|
|
396
|
+
if (!localFilesRef.current)
|
|
397
|
+
return;
|
|
327
398
|
const name = filePath ?? 'untitled.sutra';
|
|
328
399
|
const result = await storageAdapter.saveFile(name, text, null);
|
|
329
400
|
if (result) {
|
|
@@ -616,7 +687,7 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
|
|
|
616
687
|
return off;
|
|
617
688
|
}, [saveFile, showConfirm]);
|
|
618
689
|
return (_jsx(StorageAdapterProvider, { value: storageAdapter, children: _jsx(SessionProvider, { value: session ?? DEFAULT_SESSION, children: _jsx(AIProviderProvider, { value: aiProvider ?? null, children: _jsx(SpeechProviderProvider, { value: speechProvider ?? null, children: _jsx(PanelRegistryProvider, { value: panelRegistry, children: _jsx(CommandRegistryProvider, { value: commandRegistry, children: _jsx(ExportRegistryProvider, { value: exportRegistry, children: _jsx(DecorationProvidersProvider, { value: decorationProviders ?? NO_DECORATIONS, children: _jsx(CollabBindingProvider, { value: collabBinding ?? null, children: _jsx(DocumentContext.Provider, { value: {
|
|
619
|
-
text, ast, mode, filePath, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
|
|
690
|
+
text, ast, mode, filePath, fileDisplayName, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
|
|
620
691
|
voiceActive, metadataVisible,
|
|
621
692
|
findReplaceVisible, findMode, styleVisible, customStyles, resolvedStyle, watermarkText,
|
|
622
693
|
confirmModal, toasts, versions, versionHistoryVisible,
|
|
@@ -16,6 +16,7 @@ import { useCanEdit } from '../extensions/session';
|
|
|
16
16
|
import { useDecorationProviders } from '../extensions/decorations';
|
|
17
17
|
import { useCollabBinding } from '../extensions/collab';
|
|
18
18
|
import { createExternalDecorationsPlugin } from './external-decorations-plugin';
|
|
19
|
+
import { EXTERNAL_CHANGE } from './external-change';
|
|
19
20
|
export function EditorView() {
|
|
20
21
|
const { text, ast, setText, resolvedStyle } = useDocument();
|
|
21
22
|
const astRef = useRef(ast);
|
|
@@ -55,7 +56,7 @@ export function EditorView() {
|
|
|
55
56
|
},
|
|
56
57
|
editable: () => canEditRef.current,
|
|
57
58
|
dispatchTransaction(tr) {
|
|
58
|
-
if (tr.docChanged && !canEditRef.current)
|
|
59
|
+
if (tr.docChanged && !canEditRef.current && !tr.getMeta(EXTERNAL_CHANGE))
|
|
59
60
|
return;
|
|
60
61
|
const newState = view.state.apply(tr);
|
|
61
62
|
view.updateState(newState);
|
|
@@ -63,7 +64,8 @@ export function EditorView() {
|
|
|
63
64
|
const newText = prosemirrorToSutra(newState.doc);
|
|
64
65
|
if (newText !== textRef.current) {
|
|
65
66
|
selfChangeRef.current = true;
|
|
66
|
-
|
|
67
|
+
// A change applied from storage is not a user edit.
|
|
68
|
+
setText(newText, tr.getMeta(EXTERNAL_CHANGE) ? { markDirty: false } : undefined);
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
emit();
|
|
@@ -12,3 +12,6 @@ export declare function emit(): void;
|
|
|
12
12
|
/** Window event that opens the title page form (TitlePageDialog, mounted by AppShell). */
|
|
13
13
|
export declare const OPEN_TITLE_PAGE_EVENT = "cs:open-title-page";
|
|
14
14
|
export declare function openTitlePageForm(): void;
|
|
15
|
+
export declare function markExternalText(text: string): void;
|
|
16
|
+
/** True, once, if `text` is the one marked by markExternalText. */
|
|
17
|
+
export declare function takeExternalText(text: string): boolean;
|
|
@@ -37,3 +37,16 @@ export const OPEN_TITLE_PAGE_EVENT = 'cs:open-title-page';
|
|
|
37
37
|
export function openTitlePageForm() {
|
|
38
38
|
window.dispatchEvent(new CustomEvent(OPEN_TITLE_PAGE_EVENT));
|
|
39
39
|
}
|
|
40
|
+
// Text the document context is applying from storage (a SaveResult or a
|
|
41
|
+
// remote update). The source view applies it outside its undo history.
|
|
42
|
+
let externalText = null;
|
|
43
|
+
export function markExternalText(text) {
|
|
44
|
+
externalText = text;
|
|
45
|
+
}
|
|
46
|
+
/** True, once, if `text` is the one marked by markExternalText. */
|
|
47
|
+
export function takeExternalText(text) {
|
|
48
|
+
const hit = externalText === text;
|
|
49
|
+
if (hit)
|
|
50
|
+
externalText = null;
|
|
51
|
+
return hit;
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Node } from 'prosemirror-model';
|
|
2
|
+
import type { EditorState, Transaction } from 'prosemirror-state';
|
|
3
|
+
/** Transaction meta marking a change that came from storage, not the user. */
|
|
4
|
+
export declare const EXTERNAL_CHANGE = "sutrataExternalChange";
|
|
5
|
+
/**
|
|
6
|
+
* A transaction turning `state.doc` into `next` that replaces only the
|
|
7
|
+
* top-level blocks that differ (a block whose only change is its attributes
|
|
8
|
+
* keeps its content), so the selection in untouched blocks stays where it is.
|
|
9
|
+
* Kept out of undo history and marked EXTERNAL_CHANGE. Null if they are equal.
|
|
10
|
+
*/
|
|
11
|
+
export declare function externalChange(state: EditorState, next: Node): Transaction | null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Transaction meta marking a change that came from storage, not the user. */
|
|
2
|
+
export const EXTERNAL_CHANGE = 'sutrataExternalChange';
|
|
3
|
+
/** Above this many block pairs the diff gives up and replaces the whole changed middle. */
|
|
4
|
+
const MAX_DIFF_CELLS = 4_000_000;
|
|
5
|
+
/**
|
|
6
|
+
* A transaction turning `state.doc` into `next` that replaces only the
|
|
7
|
+
* top-level blocks that differ (a block whose only change is its attributes
|
|
8
|
+
* keeps its content), so the selection in untouched blocks stays where it is.
|
|
9
|
+
* Kept out of undo history and marked EXTERNAL_CHANGE. Null if they are equal.
|
|
10
|
+
*/
|
|
11
|
+
export function externalChange(state, next) {
|
|
12
|
+
if (state.doc.eq(next))
|
|
13
|
+
return null;
|
|
14
|
+
const a = children(state.doc);
|
|
15
|
+
const b = children(next);
|
|
16
|
+
let start = 0;
|
|
17
|
+
while (start < a.length && start < b.length && a[start].eq(b[start]))
|
|
18
|
+
start++;
|
|
19
|
+
let endA = a.length;
|
|
20
|
+
let endB = b.length;
|
|
21
|
+
while (endA > start && endB > start && a[endA - 1].eq(b[endB - 1])) {
|
|
22
|
+
endA--;
|
|
23
|
+
endB--;
|
|
24
|
+
}
|
|
25
|
+
const runs = diffRuns(a, b, start, endA, start, endB);
|
|
26
|
+
const posA = offsets(a);
|
|
27
|
+
const posB = offsets(b);
|
|
28
|
+
const tr = state.tr;
|
|
29
|
+
// Last run first, so earlier positions stay valid.
|
|
30
|
+
for (const r of runs.reverse()) {
|
|
31
|
+
const one = r.aTo - r.aFrom === 1 && r.bTo - r.bFrom === 1;
|
|
32
|
+
const x = a[r.aFrom];
|
|
33
|
+
const y = b[r.bFrom];
|
|
34
|
+
if (one && x && y && x.type === y.type && x.content.eq(y.content) && x.sameMarkup(y) === false &&
|
|
35
|
+
y.type.validContent(x.content)) {
|
|
36
|
+
tr.setNodeMarkup(posA[r.aFrom], undefined, y.attrs, y.marks);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
tr.replace(posA[r.aFrom], posA[r.aTo], next.slice(posB[r.bFrom], posB[r.bTo]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return tr.setMeta('addToHistory', false).setMeta(EXTERNAL_CHANGE, true);
|
|
43
|
+
}
|
|
44
|
+
function children(doc) {
|
|
45
|
+
const list = [];
|
|
46
|
+
doc.forEach(n => { list.push(n); });
|
|
47
|
+
return list;
|
|
48
|
+
}
|
|
49
|
+
/** Start position of each top-level child, plus the end of the content. */
|
|
50
|
+
function offsets(nodes) {
|
|
51
|
+
const pos = [0];
|
|
52
|
+
for (const n of nodes)
|
|
53
|
+
pos.push(pos.at(-1) + n.nodeSize);
|
|
54
|
+
return pos;
|
|
55
|
+
}
|
|
56
|
+
/** Runs of unmatched blocks between a[aStart..aEnd) and b[bStart..bEnd), by longest common subsequence. */
|
|
57
|
+
function diffRuns(a, b, aStart, aEnd, bStart, bEnd) {
|
|
58
|
+
const n = aEnd - aStart;
|
|
59
|
+
const m = bEnd - bStart;
|
|
60
|
+
if (n === 0 && m === 0)
|
|
61
|
+
return [];
|
|
62
|
+
if (n === 0 || m === 0 || n * m > MAX_DIFF_CELLS)
|
|
63
|
+
return [{ aFrom: aStart, aTo: aEnd, bFrom: bStart, bTo: bEnd }];
|
|
64
|
+
// lcs[i][j]: LCS length of a[aStart+i..aEnd) and b[bStart+j..bEnd).
|
|
65
|
+
const w = m + 1;
|
|
66
|
+
const lcs = new Uint32Array((n + 1) * w);
|
|
67
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
68
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
69
|
+
lcs[i * w + j] = a[aStart + i].eq(b[bStart + j])
|
|
70
|
+
? lcs[(i + 1) * w + j + 1] + 1
|
|
71
|
+
: Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const runs = [];
|
|
75
|
+
let run = null;
|
|
76
|
+
let i = 0;
|
|
77
|
+
let j = 0;
|
|
78
|
+
const extend = (di, dj) => {
|
|
79
|
+
if (!run)
|
|
80
|
+
run = { aFrom: aStart + i, aTo: aStart + i, bFrom: bStart + j, bTo: bStart + j };
|
|
81
|
+
run.aTo += di;
|
|
82
|
+
run.bTo += dj;
|
|
83
|
+
};
|
|
84
|
+
while (i < n || j < m) {
|
|
85
|
+
if (i < n && j < m && a[aStart + i].eq(b[bStart + j])) {
|
|
86
|
+
if (run) {
|
|
87
|
+
runs.push(run);
|
|
88
|
+
run = null;
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
else if (j < m && (i === n || lcs[i * w + j + 1] >= lcs[(i + 1) * w + j])) {
|
|
94
|
+
extend(0, 1);
|
|
95
|
+
j++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
extend(1, 0);
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (run)
|
|
103
|
+
runs.push(run);
|
|
104
|
+
return runs;
|
|
105
|
+
}
|
|
@@ -6,6 +6,9 @@ export class FrontmatterFieldView {
|
|
|
6
6
|
this.dom = document.createElement('div');
|
|
7
7
|
this.dom.className = 'cs-fm-field';
|
|
8
8
|
this.dom.dataset['key'] = node.attrs['fmKey'];
|
|
9
|
+
// Tool-private `x-` keys (format spec §5, §7.1) are hidden.
|
|
10
|
+
if (node.attrs['fmKey'].startsWith('x-'))
|
|
11
|
+
this.dom.dataset['private'] = 'true';
|
|
9
12
|
this.contentDOM = document.createElement('span');
|
|
10
13
|
this.contentDOM.className = 'cs-fm-field-content';
|
|
11
14
|
this.dom.appendChild(this.contentDOM);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Plugin } from 'prosemirror-state';
|
|
2
2
|
import type { Node as PmNode } from 'prosemirror-model';
|
|
3
3
|
/**
|
|
4
|
-
* Tool-private `x-` scene metadata (format spec §7.1)
|
|
5
|
-
* but
|
|
6
|
-
* out of those hidden blocks and
|
|
7
|
-
* into or out of them; a range
|
|
4
|
+
* Tool-private `x-` scene metadata and frontmatter keys (format spec §5, §7.1)
|
|
5
|
+
* stay in the document but are hidden (`data-private`, see schema.ts). This
|
|
6
|
+
* plugin keeps the caret out of those hidden blocks and title-page fields and
|
|
7
|
+
* stops Backspace/Delete from merging text into or out of them; a range
|
|
8
|
+
* deletion that spans one still removes it.
|
|
8
9
|
*/
|
|
9
10
|
export declare function isPrivateBlock(node: PmNode | null | undefined): boolean;
|
|
11
|
+
/** A hidden `x-` field of the title page. */
|
|
12
|
+
export declare function isPrivateField(node: PmNode | null | undefined): boolean;
|
|
10
13
|
export declare function createPrivateMetadataPlugin(): Plugin;
|
|
@@ -1,13 +1,36 @@
|
|
|
1
|
-
import { Plugin, TextSelection } from 'prosemirror-state';
|
|
1
|
+
import { Plugin, Selection, TextSelection } from 'prosemirror-state';
|
|
2
2
|
/**
|
|
3
|
-
* Tool-private `x-` scene metadata (format spec §7.1)
|
|
4
|
-
* but
|
|
5
|
-
* out of those hidden blocks and
|
|
6
|
-
* into or out of them; a range
|
|
3
|
+
* Tool-private `x-` scene metadata and frontmatter keys (format spec §5, §7.1)
|
|
4
|
+
* stay in the document but are hidden (`data-private`, see schema.ts). This
|
|
5
|
+
* plugin keeps the caret out of those hidden blocks and title-page fields and
|
|
6
|
+
* stops Backspace/Delete from merging text into or out of them; a range
|
|
7
|
+
* deletion that spans one still removes it.
|
|
7
8
|
*/
|
|
8
9
|
export function isPrivateBlock(node) {
|
|
9
10
|
return !!node && node.type.name === 'scene_metadata' && node.attrs['metaKey'].startsWith('x-');
|
|
10
11
|
}
|
|
12
|
+
/** A hidden `x-` field of the title page. */
|
|
13
|
+
export function isPrivateField(node) {
|
|
14
|
+
return !!node && node.type.name === 'frontmatter_field' && node.attrs['fmKey'].startsWith('x-');
|
|
15
|
+
}
|
|
16
|
+
function isHidden($pos) {
|
|
17
|
+
return ($pos.depth >= 1 && isPrivateBlock($pos.node(1))) || isPrivateField($pos.parent);
|
|
18
|
+
}
|
|
19
|
+
/** The nearest text selection outside hidden blocks and fields, from a hidden field at `$head`. */
|
|
20
|
+
function leaveField(state, $head, dir) {
|
|
21
|
+
for (const d of [dir, -dir]) {
|
|
22
|
+
let pos = d === 1 ? $head.after() : $head.before();
|
|
23
|
+
for (;;) {
|
|
24
|
+
const sel = Selection.findFrom(state.doc.resolve(pos), d, true);
|
|
25
|
+
if (!sel)
|
|
26
|
+
break;
|
|
27
|
+
if (!isHidden(sel.$head))
|
|
28
|
+
return state.tr.setSelection(sel);
|
|
29
|
+
pos = d === 1 ? sel.$head.after() : sel.$head.before();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
11
34
|
/** Position just inside the nearest visible top-level block from `index` in `dir`, or null. */
|
|
12
35
|
function visibleBlockPos(doc, index, dir) {
|
|
13
36
|
for (let i = index; i >= 0 && i < doc.childCount; i += dir) {
|
|
@@ -28,11 +51,13 @@ export function createPrivateMetadataPlugin() {
|
|
|
28
51
|
return new Plugin({
|
|
29
52
|
appendTransaction(trs, oldState, state) {
|
|
30
53
|
const { $head, empty } = state.selection;
|
|
31
|
-
if (!empty ||
|
|
32
|
-
return null;
|
|
33
|
-
if (!trs.some(tr => tr.selectionSet || tr.docChanged))
|
|
54
|
+
if (!empty || !trs.some(tr => tr.selectionSet || tr.docChanged))
|
|
34
55
|
return null;
|
|
35
56
|
const dir = $head.pos < oldState.selection.head ? -1 : 1;
|
|
57
|
+
if (isPrivateField($head.parent))
|
|
58
|
+
return leaveField(state, $head, dir);
|
|
59
|
+
if ($head.depth < 1 || !isPrivateBlock($head.node(1)))
|
|
60
|
+
return null;
|
|
36
61
|
return moveOut(state, $head.index(0), dir);
|
|
37
62
|
},
|
|
38
63
|
props: {
|
|
@@ -40,9 +65,24 @@ export function createPrivateMetadataPlugin() {
|
|
|
40
65
|
if (event.key !== 'Backspace' && event.key !== 'Delete')
|
|
41
66
|
return false;
|
|
42
67
|
const { $head, empty } = view.state.selection;
|
|
43
|
-
if (!empty
|
|
68
|
+
if (!empty)
|
|
44
69
|
return false;
|
|
45
70
|
const back = event.key === 'Backspace';
|
|
71
|
+
if ($head.parent.type.name === 'frontmatter_field') {
|
|
72
|
+
// Between title-page fields: step over hidden ones instead of merging.
|
|
73
|
+
if (back ? $head.parentOffset !== 0 : $head.parentOffset !== $head.parent.content.size)
|
|
74
|
+
return false;
|
|
75
|
+
const page = $head.node($head.depth - 1);
|
|
76
|
+
const neighbour = $head.index($head.depth - 1) + (back ? -1 : 1);
|
|
77
|
+
if (neighbour < 0 || neighbour >= page.childCount || !isPrivateField(page.child(neighbour)))
|
|
78
|
+
return false;
|
|
79
|
+
const tr = leaveField(view.state, view.state.doc.resolve(back ? $head.before() - 1 : $head.after() + 1), back ? -1 : 1);
|
|
80
|
+
if (tr)
|
|
81
|
+
view.dispatch(tr);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if ($head.depth !== 1)
|
|
85
|
+
return false;
|
|
46
86
|
if (back ? $head.parentOffset !== 0 : $head.parentOffset !== $head.parent.content.size)
|
|
47
87
|
return false;
|
|
48
88
|
const index = $head.index(0);
|
package/dist/editor/schema.js
CHANGED
|
@@ -36,7 +36,11 @@ const nodes = {
|
|
|
36
36
|
content: '(text | hard_break)*',
|
|
37
37
|
marks: '',
|
|
38
38
|
attrs: { fmKey: { default: '' } },
|
|
39
|
-
toDOM: (node) =>
|
|
39
|
+
toDOM: (node) => {
|
|
40
|
+
const key = node.attrs['fmKey'];
|
|
41
|
+
// Tool-private `x-` keys (format spec §5, §7.1) stay in the text but are not shown.
|
|
42
|
+
return ['div', { class: 'cs-fm-field', 'data-key': key, ...(key.startsWith('x-') ? { 'data-private': 'true' } : {}) }, 0];
|
|
43
|
+
},
|
|
40
44
|
parseDOM: [{ tag: 'div.cs-fm-field', getAttrs: (dom) => ({ fmKey: dom.dataset['key'] ?? '' }) }],
|
|
41
45
|
},
|
|
42
46
|
scene_heading: {
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* - `comment`: like `read`; decoration providers (comment threads) may still
|
|
12
12
|
* handle clicks.
|
|
13
13
|
*
|
|
14
|
-
* `featureFlags`: `ai: false` hides AI features, `voice: false` hides voice
|
|
14
|
+
* `featureFlags`: `ai: false` hides AI features, `voice: false` hides voice;
|
|
15
|
+
* `localFiles: false` hides New, Open and Save As, for embedders whose
|
|
16
|
+
* documents are not local files (Save stays: it saves through the adapter).
|
|
17
|
+
*
|
|
15
18
|
* dictation. Unset flags count as enabled.
|
|
16
19
|
*/
|
|
17
20
|
export interface SessionContext {
|
|
@@ -9,8 +9,30 @@ import type { ScreenplayStyleDefinition } from '../styles/types';
|
|
|
9
9
|
* has no equivalent in a non-web adapter. Callers just thread whatever the
|
|
10
10
|
* adapter returned back into a later saveFile() call without inspecting it.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* What a networked adapter may return from saveDocument (OSS spec §11.5).
|
|
14
|
+
*/
|
|
15
|
+
export interface SaveResult {
|
|
16
|
+
/**
|
|
17
|
+
* The document as the adapter now holds it, e.g. merged with changes saved
|
|
18
|
+
* elsewhere, or with lines the adapter adds (such as tool-private `x-` keys).
|
|
19
|
+
* The editor applies it if it differs from what was saved, as one change kept
|
|
20
|
+
* out of undo history, so long as the document has not been edited since the
|
|
21
|
+
* save began; otherwise it is dropped and the next save carries the edits.
|
|
22
|
+
*/
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
12
25
|
export interface StorageAdapter {
|
|
13
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Autosave. Local adapters return nothing; networked ones may return a
|
|
28
|
+
* SaveResult (see there).
|
|
29
|
+
*
|
|
30
|
+
* A dropped result or remote update (see subscribe) is never lost as long as
|
|
31
|
+
* the adapter bases its next save on the content it was last *sent*, not on
|
|
32
|
+
* what it returned: the next save then carries the local edits, and the
|
|
33
|
+
* adapter merges it again.
|
|
34
|
+
*/
|
|
35
|
+
saveDocument(path: string, content: string): Promise<void | SaveResult>;
|
|
14
36
|
loadDocument(path: string): Promise<string | null>;
|
|
15
37
|
listVersions(path: string): Promise<VersionEntry[]>;
|
|
16
38
|
openFile(): Promise<{
|
|
@@ -54,6 +76,19 @@ export interface StorageAdapter {
|
|
|
54
76
|
* persist a new version entry when there is a difference against the previous version.
|
|
55
77
|
*/
|
|
56
78
|
saveVersion?(path: string, content: string): Promise<boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* Optional: changes to `path` saved elsewhere (another device, a merge).
|
|
81
|
+
* `onRemote` receives the whole document. The editor applies it like a
|
|
82
|
+
* SaveResult, and only if there are no local edits since the last save;
|
|
83
|
+
* otherwise it is dropped and the next save merges them. Returns an
|
|
84
|
+
* unsubscribe function. Called for the open document's path (filePath).
|
|
85
|
+
*/
|
|
86
|
+
subscribe?(path: string, onRemote: (content: string) => void): () => void;
|
|
87
|
+
/**
|
|
88
|
+
* Optional: the name to show for `path` (e.g. a document title when paths
|
|
89
|
+
* are opaque ids). Defaults to the path itself.
|
|
90
|
+
*/
|
|
91
|
+
displayName?(path: string): string;
|
|
57
92
|
}
|
|
58
93
|
export declare const StorageAdapterProvider: import("react").Provider<StorageAdapter | null>;
|
|
59
94
|
export declare function useStorageAdapter(): StorageAdapter;
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { LanguageProvider, useLanguage } from './i18n/LanguageContext';
|
|
|
6
6
|
export { TranslationProvider, useTranslation } from './i18n/useTranslation';
|
|
7
7
|
export type { EditorMode, VersionEntry } from './types';
|
|
8
8
|
export type { ScreenplayStyleDefinition } from './styles/types';
|
|
9
|
-
export type { StorageAdapter } from './extensions/storage-adapter';
|
|
9
|
+
export type { StorageAdapter, SaveResult } from './extensions/storage-adapter';
|
|
10
10
|
export { useStorageAdapter } from './extensions/storage-adapter';
|
|
11
11
|
export type { AIProvider, AIRequest, AIActivity, JsonSchema } from './extensions/ai-provider';
|
|
12
12
|
export { useAI } from './extensions/ai-provider';
|
package/dist/shell/AppBar.js
CHANGED
|
@@ -9,7 +9,7 @@ import { SutrataLogo } from './SutrataLogo';
|
|
|
9
9
|
import { getTemplate } from '../templates/templates';
|
|
10
10
|
import { openTitlePageForm } from '../titlepage/TitlePageDialog';
|
|
11
11
|
import { useSpeech } from '../extensions/speech-provider';
|
|
12
|
-
import { useCanEdit } from '../extensions/session';
|
|
12
|
+
import { useCanEdit, useSession, isFeatureEnabled } from '../extensions/session';
|
|
13
13
|
import { usePanels } from '../extensions/panel-registry';
|
|
14
14
|
import { usePanelContext } from './use-panel-context';
|
|
15
15
|
import { useCommands, useCommandContext, displayShortcut } from './use-commands';
|
|
@@ -18,7 +18,7 @@ function AbBtn({ label, shortcut, active, variant, onClick, children }) {
|
|
|
18
18
|
return (_jsxs("button", { type: "button", "aria-label": label, className: `cs-ab-btn${active ? ' cs-ab-active' : ''}${variantClass}`, onClick: onClick, children: [children, _jsxs("span", { className: "cs-tb-tip", role: "tooltip", children: [_jsx("span", { children: label }), shortcut ? _jsx("span", { className: "cs-tb-key", children: shortcut }) : null] })] }));
|
|
19
19
|
}
|
|
20
20
|
export function AppBar() {
|
|
21
|
-
const { mode, setMode, isDirty, filePath, ribbonVisible, setRibbonVisible, navVisible, setNavVisible, exportVisible, setExportVisible, settingsVisible, setSettingsVisible, metadataVisible, setMetadataVisible, voiceActive, setVoiceActive, newDocument, openFile, saveFile, saveFileAs, setText, importDocx, styleVisible, setStyleVisible, showConfirm, showToast, } = useDocument();
|
|
21
|
+
const { mode, setMode, isDirty, filePath, fileDisplayName, ribbonVisible, setRibbonVisible, navVisible, setNavVisible, exportVisible, setExportVisible, settingsVisible, setSettingsVisible, metadataVisible, setMetadataVisible, voiceActive, setVoiceActive, newDocument, openFile, saveFile, saveFileAs, setText, importDocx, styleVisible, setStyleVisible, showConfirm, showToast, } = useDocument();
|
|
22
22
|
const { t, locale } = useTranslation();
|
|
23
23
|
const speech = useSpeech();
|
|
24
24
|
// AI-assisted import needs a provider and an edit session (useAI covers both).
|
|
@@ -33,7 +33,9 @@ export function AppBar() {
|
|
|
33
33
|
const commandContext = useCommandContext();
|
|
34
34
|
const panelContext = usePanelContext();
|
|
35
35
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
36
|
-
const name = filePath ?? 'untitled.sutra';
|
|
36
|
+
const name = fileDisplayName ?? filePath ?? 'untitled.sutra';
|
|
37
|
+
// Embedders whose documents are not local files turn these off.
|
|
38
|
+
const localFiles = isFeatureEnabled(useSession(), 'localFiles');
|
|
37
39
|
const nextMode = mode === 'formatted' ? 'source' : 'formatted';
|
|
38
40
|
const handleNew = () => {
|
|
39
41
|
newDocument(getTemplate(locale));
|
|
@@ -49,5 +51,5 @@ export function AppBar() {
|
|
|
49
51
|
showToast(err instanceof Error ? err.message : 'Failed to import the Word document.', 'error');
|
|
50
52
|
}
|
|
51
53
|
};
|
|
52
|
-
return (_jsxs("header", { className: "cs-app-bar", role: "banner", "aria-label": "Application header", children: [_jsxs("div", { className: "cs-ab-group cs-ab-left", children: [_jsxs("div", { className: "cs-ab-brand", title: "Sutrata Multilingual Screenplay Editor", children: [_jsx(SutrataLogo, { size: 20, showText: false }), _jsx("span", { className: "cs-ab-brand-title", children: "Sutrata" })] }), _jsx("span", { className: "cs-ab-sep cs-ab-desktop-only" }), _jsx(AbBtn, { label: t('appbar.navigator'), shortcut: "Ctrl+Shift+B", active: navVisible, onClick: () => setNavVisible(!navVisible), children: _jsx(NavigatorIcon, { size: 18 }) }), canEdit && (_jsx("span", { className: "cs-ab-desktop-only", children: _jsx(AbBtn, { label: t('appbar.titlePage'), onClick: openTitlePageForm, children: _jsx(TitlePageIcon, { size: 18 }) }) })), _jsxs("div", { className: "cs-ab-file-pill", title: filePath ?? 'untitled.sutra', children: [_jsx("span", { className: "cs-ab-file-name", children: name }), isDirty && _jsx("span", { className: "cs-ab-dirty", title: "Unsaved edits" })] })] }), _jsx("span", { className: "cs-ab-spacer cs-ab-desktop-only" }), _jsxs("div", { className: "cs-ab-group cs-ab-desktop-group", children: [_jsx(AbBtn, { label: mode === 'formatted' ? t('appbar.switchToSource') : t('appbar.switchToFormatted'), shortcut: "Ctrl+Shift+E", onClick: () => setMode(nextMode), children: mode === 'formatted' ? _jsx(SourceIcon, { size: 18 }) : _jsx(FormattedIcon, { size: 18 }) }), canEdit && (_jsx(AbBtn, { label: ribbonVisible ? t('appbar.hideToolbar') : t('appbar.showToolbar'), shortcut: "Ctrl+Shift+H", active: ribbonVisible, onClick: () => setRibbonVisible(!ribbonVisible), children: _jsx(RibbonIcon, { size: 18 }) })), speech && (_jsx(AbBtn, { label: "Voice Dictation", shortcut: "Ctrl+Shift+V", active: voiceActive, onClick: () => setVoiceActive(!voiceActive), children: _jsx(MicIcon, { size: 18 }) })), _jsx(AbBtn, { label: metadataVisible ? 'Hide Metadata' : 'Show Metadata', active: metadataVisible, onClick: () => setMetadataVisible(!metadataVisible), children: _jsx("span", { style: { fontWeight: 700, fontSize: '13px' }, children: "&" }) })] }), _jsx("span", { className: "cs-ab-spacer cs-ab-desktop-only" }), _jsxs("div", { className: "cs-ab-group cs-ab-desktop-group", children: [canEdit && (_jsxs(_Fragment, { children: [_jsx(AbBtn, { label: t('appbar.new'), onClick: handleNew, children: _jsx(NewIcon, { size: 18 }) }), _jsx(AbBtn, { label: t('appbar.open'), shortcut: "Ctrl+O", onClick: () => { void openFile(); }, children: _jsx(OpenIcon, { size: 18 }) }), _jsx(AbBtn, { label: t('appbar.importDocx'), onClick: () => { void handleImportDocx(); }, children: _jsx(ImportDocxIcon, { size: 18 }) }), ai && (_jsx(AbBtn, { label: "Import with AI", onClick: () => setAiImportOpen(true), children: _jsx(SparklesIcon, { size: 18 }) })), _jsx("span", { className: "cs-ab-sep" }), _jsx(AbBtn, { label: t('appbar.save'), shortcut: "Ctrl+S", variant: "primary", onClick: () => { void saveFile(); }, children: _jsx(SaveIcon, { size: 17 }) }), _jsx(AbBtn, { label: t('appbar.saveAs'), shortcut: "Ctrl+Shift+S", onClick: () => { void saveFileAs(); }, children: _jsx(SaveAsIcon, { size: 17 }) }), _jsx("span", { className: "cs-ab-sep" })] })), _jsx(AbBtn, { label: t('appbar.export'), variant: "secondary", active: exportVisible, onClick: () => setExportVisible(!exportVisible), children: _jsx(ExportIcon, { size: 18 }) }), canEdit && (_jsx(AbBtn, { label: t('appbar.style'), active: styleVisible, onClick: () => setStyleVisible(!styleVisible), children: _jsx(StyleIcon, { size: 18 }) })), (appbarCommands.length > 0 || appbarPanels.length > 0) && _jsx("span", { className: "cs-ab-sep" }), appbarCommands.map(c => (_jsx(AbBtn, { label: c.label, shortcut: displayShortcut(c.shortcut ?? c.shortcutHint), onClick: () => c.run(commandContext()), children: c.icon ? c.icon(18) : _jsx("span", { className: "cs-ab-text-label", children: c.label }) }, c.id))), appbarPanels.map(p => (_jsx("span", { className: "cs-ab-panel", "aria-label": p.title, children: p.render(panelContext) }, p.id))), _jsx(AbBtn, { label: t('settings.title'), active: settingsVisible, onClick: () => setSettingsVisible(!settingsVisible), children: _jsx(SettingsIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-ab-group cs-ab-mobile-controls", children: [canEdit && (_jsx(AbBtn, { label: t('appbar.save'), variant: isDirty ? 'primary' : undefined, onClick: () => { void saveFile(); }, children: _jsx(SaveIcon, { size: 17 }) })), _jsx(AbBtn, { label: "Menu", active: mobileMenuOpen, onClick: () => setMobileMenuOpen(!mobileMenuOpen), children: mobileMenuOpen ? _jsx(CloseIcon, { size: 18 }) : _jsx(MenuIcon, { size: 18 }) })] }), mobileMenuOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-mobile-menu-backdrop", "aria-hidden": "true", onClick: () => setMobileMenuOpen(false) }), _jsxs("div", { className: "cs-mobile-menu-drawer", role: "dialog", "aria-modal": "true", "aria-label": "Application Menu", children: [_jsxs("div", { className: "cs-mm-header", children: [_jsxs("div", { className: "cs-mm-brand", children: [_jsx(SutrataLogo, { size: 22, showText: false }), _jsx("span", { children: "Sutrata" })] }), _jsx("button", { type: "button", className: "cs-mm-close", "aria-label": "Close menu", onClick: () => setMobileMenuOpen(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-mm-file-info", children: [_jsx("span", { className: "cs-mm-file-name", children: name }), isDirty && _jsx("span", { className: "cs-ab-dirty", title: "Unsaved edits" })] }), _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "Views & Tools" }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setMode(nextMode); setMobileMenuOpen(false); }, children: [mode === 'formatted' ? _jsx(SourceIcon, { size: 18 }) : _jsx(FormattedIcon, { size: 18 }), _jsx("span", { children: mode === 'formatted' ? t('appbar.switchToSource') : t('appbar.switchToFormatted') })] }), _jsxs("button", { type: "button", className: `cs-mm-item${navVisible ? ' cs-mm-active' : ''}`, onClick: () => { setNavVisible(!navVisible); setMobileMenuOpen(false); }, children: [_jsx(NavigatorIcon, { size: 18 }), _jsx("span", { children: t('appbar.navigator') })] }), canEdit && _jsxs("button", { type: "button", className: `cs-mm-item${ribbonVisible ? ' cs-mm-active' : ''}`, onClick: () => { setRibbonVisible(!ribbonVisible); setMobileMenuOpen(false); }, children: [_jsx(RibbonIcon, { size: 18 }), _jsx("span", { children: ribbonVisible ? t('appbar.hideToolbar') : t('appbar.showToolbar') })] }), speech && _jsxs("button", { type: "button", className: `cs-mm-item${voiceActive ? ' cs-mm-active' : ''}`, onClick: () => { setVoiceActive(!voiceActive); setMobileMenuOpen(false); }, children: [_jsx(MicIcon, { size: 18 }), _jsx("span", { children: "Voice Dictation" })] }), _jsxs("button", { type: "button", className: `cs-mm-item${metadataVisible ? ' cs-mm-active' : ''}`, onClick: () => { setMetadataVisible(!metadataVisible); setMobileMenuOpen(false); }, children: [_jsx("span", { style: { fontWeight: 700, width: 18, textAlign: 'center' }, children: "&" }), _jsx("span", { children: metadataVisible ? 'Hide Metadata' : 'Show Metadata' })] })] }), canEdit && _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "File Operations" }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { openTitlePageForm(); setMobileMenuOpen(false); }, children: [_jsx(TitlePageIcon, { size: 18 }), _jsx("span", { children: t('appbar.titlePage') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { handleNew(); setMobileMenuOpen(false); }, children: [_jsx(NewIcon, { size: 18 }), _jsx("span", { children: t('appbar.new') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void openFile(); setMobileMenuOpen(false); }, children: [_jsx(OpenIcon, { size: 18 }), _jsx("span", { children: t('appbar.open') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void handleImportDocx(); setMobileMenuOpen(false); }, children: [_jsx(ImportDocxIcon, { size: 18 }), _jsx("span", { children: t('appbar.importDocx') })] }), ai && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setAiImportOpen(true); setMobileMenuOpen(false); }, children: [_jsx(SparklesIcon, { size: 18 }), _jsx("span", { children: "Import with AI" })] }), _jsxs("button", { type: "button", className: "cs-mm-item cs-mm-primary", onClick: () => { void saveFile(); setMobileMenuOpen(false); }, children: [_jsx(SaveIcon, { size: 18 }), _jsx("span", { children: t('appbar.save') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void saveFileAs(); setMobileMenuOpen(false); }, children: [_jsx(SaveAsIcon, { size: 18 }), _jsx("span", { children: t('appbar.saveAs') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setExportVisible(true); setMobileMenuOpen(false); }, children: [_jsx(ExportIcon, { size: 18 }), _jsx("span", { children: t('appbar.export') })] })] }), !canEdit && _jsx("div", { className: "cs-mm-section", children: _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setExportVisible(true); setMobileMenuOpen(false); }, children: [_jsx(ExportIcon, { size: 18 }), _jsx("span", { children: t('appbar.export') })] }) }), appbarCommands.length > 0 && _jsx("div", { className: "cs-mm-section", children: appbarCommands.map(c => (_jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { c.run(commandContext()); setMobileMenuOpen(false); }, children: [c.icon?.(18), _jsx("span", { children: c.label })] }, c.id))) }), _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "Preferences" }), canEdit && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setStyleVisible(true); setMobileMenuOpen(false); }, children: [_jsx(StyleIcon, { size: 18 }), _jsx("span", { children: t('appbar.style') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setSettingsVisible(true); setMobileMenuOpen(false); }, children: [_jsx(SettingsIcon, { size: 18 }), _jsx("span", { children: t('settings.title') })] })] })] })] })), aiImportOpen && _jsx(AIImportDialog, { onClose: () => setAiImportOpen(false) })] }));
|
|
54
|
+
return (_jsxs("header", { className: "cs-app-bar", role: "banner", "aria-label": "Application header", children: [_jsxs("div", { className: "cs-ab-group cs-ab-left", children: [_jsxs("div", { className: "cs-ab-brand", title: "Sutrata Multilingual Screenplay Editor", children: [_jsx(SutrataLogo, { size: 20, showText: false }), _jsx("span", { className: "cs-ab-brand-title", children: "Sutrata" })] }), _jsx("span", { className: "cs-ab-sep cs-ab-desktop-only" }), _jsx(AbBtn, { label: t('appbar.navigator'), shortcut: "Ctrl+Shift+B", active: navVisible, onClick: () => setNavVisible(!navVisible), children: _jsx(NavigatorIcon, { size: 18 }) }), canEdit && (_jsx("span", { className: "cs-ab-desktop-only", children: _jsx(AbBtn, { label: t('appbar.titlePage'), onClick: openTitlePageForm, children: _jsx(TitlePageIcon, { size: 18 }) }) })), _jsxs("div", { className: "cs-ab-file-pill", title: name, children: [_jsx("span", { className: "cs-ab-file-name", children: name }), isDirty && _jsx("span", { className: "cs-ab-dirty", title: "Unsaved edits" })] })] }), _jsx("span", { className: "cs-ab-spacer cs-ab-desktop-only" }), _jsxs("div", { className: "cs-ab-group cs-ab-desktop-group", children: [_jsx(AbBtn, { label: mode === 'formatted' ? t('appbar.switchToSource') : t('appbar.switchToFormatted'), shortcut: "Ctrl+Shift+E", onClick: () => setMode(nextMode), children: mode === 'formatted' ? _jsx(SourceIcon, { size: 18 }) : _jsx(FormattedIcon, { size: 18 }) }), canEdit && (_jsx(AbBtn, { label: ribbonVisible ? t('appbar.hideToolbar') : t('appbar.showToolbar'), shortcut: "Ctrl+Shift+H", active: ribbonVisible, onClick: () => setRibbonVisible(!ribbonVisible), children: _jsx(RibbonIcon, { size: 18 }) })), speech && (_jsx(AbBtn, { label: "Voice Dictation", shortcut: "Ctrl+Shift+V", active: voiceActive, onClick: () => setVoiceActive(!voiceActive), children: _jsx(MicIcon, { size: 18 }) })), _jsx(AbBtn, { label: metadataVisible ? 'Hide Metadata' : 'Show Metadata', active: metadataVisible, onClick: () => setMetadataVisible(!metadataVisible), children: _jsx("span", { style: { fontWeight: 700, fontSize: '13px' }, children: "&" }) })] }), _jsx("span", { className: "cs-ab-spacer cs-ab-desktop-only" }), _jsxs("div", { className: "cs-ab-group cs-ab-desktop-group", children: [canEdit && (_jsxs(_Fragment, { children: [localFiles && (_jsxs(_Fragment, { children: [_jsx(AbBtn, { label: t('appbar.new'), onClick: handleNew, children: _jsx(NewIcon, { size: 18 }) }), _jsx(AbBtn, { label: t('appbar.open'), shortcut: "Ctrl+O", onClick: () => { void openFile(); }, children: _jsx(OpenIcon, { size: 18 }) })] })), _jsx(AbBtn, { label: t('appbar.importDocx'), onClick: () => { void handleImportDocx(); }, children: _jsx(ImportDocxIcon, { size: 18 }) }), ai && (_jsx(AbBtn, { label: "Import with AI", onClick: () => setAiImportOpen(true), children: _jsx(SparklesIcon, { size: 18 }) })), _jsx("span", { className: "cs-ab-sep" }), _jsx(AbBtn, { label: t('appbar.save'), shortcut: "Ctrl+S", variant: "primary", onClick: () => { void saveFile(); }, children: _jsx(SaveIcon, { size: 17 }) }), localFiles && (_jsx(AbBtn, { label: t('appbar.saveAs'), shortcut: "Ctrl+Shift+S", onClick: () => { void saveFileAs(); }, children: _jsx(SaveAsIcon, { size: 17 }) })), _jsx("span", { className: "cs-ab-sep" })] })), _jsx(AbBtn, { label: t('appbar.export'), variant: "secondary", active: exportVisible, onClick: () => setExportVisible(!exportVisible), children: _jsx(ExportIcon, { size: 18 }) }), canEdit && (_jsx(AbBtn, { label: t('appbar.style'), active: styleVisible, onClick: () => setStyleVisible(!styleVisible), children: _jsx(StyleIcon, { size: 18 }) })), (appbarCommands.length > 0 || appbarPanels.length > 0) && _jsx("span", { className: "cs-ab-sep" }), appbarCommands.map(c => (_jsx(AbBtn, { label: c.label, shortcut: displayShortcut(c.shortcut ?? c.shortcutHint), onClick: () => c.run(commandContext()), children: c.icon ? c.icon(18) : _jsx("span", { className: "cs-ab-text-label", children: c.label }) }, c.id))), appbarPanels.map(p => (_jsx("span", { className: "cs-ab-panel", "aria-label": p.title, children: p.render(panelContext) }, p.id))), _jsx(AbBtn, { label: t('settings.title'), active: settingsVisible, onClick: () => setSettingsVisible(!settingsVisible), children: _jsx(SettingsIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-ab-group cs-ab-mobile-controls", children: [appbarPanels.map(p => (_jsx("span", { className: "cs-ab-panel", "aria-label": p.title, children: p.render(panelContext) }, p.id))), canEdit && (_jsx(AbBtn, { label: t('appbar.save'), variant: isDirty ? 'primary' : undefined, onClick: () => { void saveFile(); }, children: _jsx(SaveIcon, { size: 17 }) })), _jsx(AbBtn, { label: "Menu", active: mobileMenuOpen, onClick: () => setMobileMenuOpen(!mobileMenuOpen), children: mobileMenuOpen ? _jsx(CloseIcon, { size: 18 }) : _jsx(MenuIcon, { size: 18 }) })] }), mobileMenuOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-mobile-menu-backdrop", "aria-hidden": "true", onClick: () => setMobileMenuOpen(false) }), _jsxs("div", { className: "cs-mobile-menu-drawer", role: "dialog", "aria-modal": "true", "aria-label": "Application Menu", children: [_jsxs("div", { className: "cs-mm-header", children: [_jsxs("div", { className: "cs-mm-brand", children: [_jsx(SutrataLogo, { size: 22, showText: false }), _jsx("span", { children: "Sutrata" })] }), _jsx("button", { type: "button", className: "cs-mm-close", "aria-label": "Close menu", onClick: () => setMobileMenuOpen(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-mm-file-info", children: [_jsx("span", { className: "cs-mm-file-name", children: name }), isDirty && _jsx("span", { className: "cs-ab-dirty", title: "Unsaved edits" })] }), _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "Views & Tools" }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setMode(nextMode); setMobileMenuOpen(false); }, children: [mode === 'formatted' ? _jsx(SourceIcon, { size: 18 }) : _jsx(FormattedIcon, { size: 18 }), _jsx("span", { children: mode === 'formatted' ? t('appbar.switchToSource') : t('appbar.switchToFormatted') })] }), _jsxs("button", { type: "button", className: `cs-mm-item${navVisible ? ' cs-mm-active' : ''}`, onClick: () => { setNavVisible(!navVisible); setMobileMenuOpen(false); }, children: [_jsx(NavigatorIcon, { size: 18 }), _jsx("span", { children: t('appbar.navigator') })] }), canEdit && _jsxs("button", { type: "button", className: `cs-mm-item${ribbonVisible ? ' cs-mm-active' : ''}`, onClick: () => { setRibbonVisible(!ribbonVisible); setMobileMenuOpen(false); }, children: [_jsx(RibbonIcon, { size: 18 }), _jsx("span", { children: ribbonVisible ? t('appbar.hideToolbar') : t('appbar.showToolbar') })] }), speech && _jsxs("button", { type: "button", className: `cs-mm-item${voiceActive ? ' cs-mm-active' : ''}`, onClick: () => { setVoiceActive(!voiceActive); setMobileMenuOpen(false); }, children: [_jsx(MicIcon, { size: 18 }), _jsx("span", { children: "Voice Dictation" })] }), _jsxs("button", { type: "button", className: `cs-mm-item${metadataVisible ? ' cs-mm-active' : ''}`, onClick: () => { setMetadataVisible(!metadataVisible); setMobileMenuOpen(false); }, children: [_jsx("span", { style: { fontWeight: 700, width: 18, textAlign: 'center' }, children: "&" }), _jsx("span", { children: metadataVisible ? 'Hide Metadata' : 'Show Metadata' })] })] }), canEdit && _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "File Operations" }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { openTitlePageForm(); setMobileMenuOpen(false); }, children: [_jsx(TitlePageIcon, { size: 18 }), _jsx("span", { children: t('appbar.titlePage') })] }), localFiles && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { handleNew(); setMobileMenuOpen(false); }, children: [_jsx(NewIcon, { size: 18 }), _jsx("span", { children: t('appbar.new') })] }), localFiles && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void openFile(); setMobileMenuOpen(false); }, children: [_jsx(OpenIcon, { size: 18 }), _jsx("span", { children: t('appbar.open') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void handleImportDocx(); setMobileMenuOpen(false); }, children: [_jsx(ImportDocxIcon, { size: 18 }), _jsx("span", { children: t('appbar.importDocx') })] }), ai && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setAiImportOpen(true); setMobileMenuOpen(false); }, children: [_jsx(SparklesIcon, { size: 18 }), _jsx("span", { children: "Import with AI" })] }), _jsxs("button", { type: "button", className: "cs-mm-item cs-mm-primary", onClick: () => { void saveFile(); setMobileMenuOpen(false); }, children: [_jsx(SaveIcon, { size: 18 }), _jsx("span", { children: t('appbar.save') })] }), localFiles && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { void saveFileAs(); setMobileMenuOpen(false); }, children: [_jsx(SaveAsIcon, { size: 18 }), _jsx("span", { children: t('appbar.saveAs') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setExportVisible(true); setMobileMenuOpen(false); }, children: [_jsx(ExportIcon, { size: 18 }), _jsx("span", { children: t('appbar.export') })] })] }), !canEdit && _jsx("div", { className: "cs-mm-section", children: _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setExportVisible(true); setMobileMenuOpen(false); }, children: [_jsx(ExportIcon, { size: 18 }), _jsx("span", { children: t('appbar.export') })] }) }), appbarCommands.length > 0 && _jsx("div", { className: "cs-mm-section", children: appbarCommands.map(c => (_jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { c.run(commandContext()); setMobileMenuOpen(false); }, children: [c.icon?.(18), _jsx("span", { children: c.label })] }, c.id))) }), _jsxs("div", { className: "cs-mm-section", children: [_jsx("div", { className: "cs-mm-section-title", children: "Preferences" }), canEdit && _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setStyleVisible(true); setMobileMenuOpen(false); }, children: [_jsx(StyleIcon, { size: 18 }), _jsx("span", { children: t('appbar.style') })] }), _jsxs("button", { type: "button", className: "cs-mm-item", onClick: () => { setSettingsVisible(true); setMobileMenuOpen(false); }, children: [_jsx(SettingsIcon, { size: 18 }), _jsx("span", { children: t('settings.title') })] })] })] })] })), aiImportOpen && _jsx(AIImportDialog, { onClose: () => setAiImportOpen(false) })] }));
|
|
53
55
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef } from 'react';
|
|
3
|
-
import { Annotation, Compartment, EditorState } from '@codemirror/state';
|
|
3
|
+
import { Annotation, Compartment, EditorState, Transaction } from '@codemirror/state';
|
|
4
4
|
import { EditorView, keymap } from '@codemirror/view';
|
|
5
5
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
6
6
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
7
7
|
import { tags } from '@lezer/highlight';
|
|
8
8
|
import { sutraLanguage } from './sutra-lang';
|
|
9
9
|
import { useDocument } from '../context/DocumentContext';
|
|
10
|
-
import { setSourceView } from '../editor/editor-bus';
|
|
10
|
+
import { setSourceView, takeExternalText } from '../editor/editor-bus';
|
|
11
11
|
import { sourceFindHighlightExtension } from './find-highlight-source';
|
|
12
12
|
import { useCanEdit } from '../extensions/session';
|
|
13
13
|
/** Marks the transaction that mirrors an external text change into the view. */
|
|
@@ -84,7 +84,8 @@ export function SourceView() {
|
|
|
84
84
|
editableCompartment.current.of([EditorView.editable.of(canEdit), EditorState.readOnly.of(!canEdit)]),
|
|
85
85
|
EditorState.transactionFilter.of(tr => tr.docChanged && !canEditRef.current && !tr.annotation(externalSync) ? [] : tr),
|
|
86
86
|
EditorView.updateListener.of(update => {
|
|
87
|
-
|
|
87
|
+
// Mirrored text (externalSync) is already the document's text.
|
|
88
|
+
if (update.docChanged && !update.transactions.some(t => t.annotation(externalSync))) {
|
|
88
89
|
const newText = update.state.doc.toString();
|
|
89
90
|
lastTextRef.current = newText;
|
|
90
91
|
setText(newText);
|
|
@@ -109,6 +110,24 @@ export function SourceView() {
|
|
|
109
110
|
if (!view || text === lastTextRef.current)
|
|
110
111
|
return;
|
|
111
112
|
lastTextRef.current = text;
|
|
113
|
+
if (takeExternalText(text)) {
|
|
114
|
+
// From storage: change only what differs, keep the cursor, not undoable.
|
|
115
|
+
const old = view.state.doc.toString();
|
|
116
|
+
let from = 0;
|
|
117
|
+
while (from < old.length && from < text.length && old[from] === text[from])
|
|
118
|
+
from++;
|
|
119
|
+
let endOld = old.length;
|
|
120
|
+
let endNew = text.length;
|
|
121
|
+
while (endOld > from && endNew > from && old[endOld - 1] === text[endNew - 1]) {
|
|
122
|
+
endOld--;
|
|
123
|
+
endNew--;
|
|
124
|
+
}
|
|
125
|
+
view.dispatch({
|
|
126
|
+
changes: { from, to: endOld, insert: text.slice(from, endNew) },
|
|
127
|
+
annotations: [externalSync.of(true), Transaction.addToHistory.of(false)],
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
112
131
|
view.dispatch({
|
|
113
132
|
changes: { from: 0, to: view.state.doc.length, insert: text },
|
|
114
133
|
annotations: externalSync.of(true),
|
|
@@ -508,7 +508,8 @@ html, body, #root { height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
|
|
508
508
|
}
|
|
509
509
|
|
|
510
510
|
/* Tool-private `x-` keys (format spec §7.1) stay in the text but are not shown */
|
|
511
|
-
.cs-scene-metadata[data-private]
|
|
511
|
+
.cs-scene-metadata[data-private],
|
|
512
|
+
.cs-fm-field[data-private] {
|
|
512
513
|
display: none;
|
|
513
514
|
}
|
|
514
515
|
|
|
@@ -87,7 +87,7 @@ export function readTitlePage(data) {
|
|
|
87
87
|
f.watermark = str(data['watermark']);
|
|
88
88
|
const managed = new Set(MANAGED_KEYS);
|
|
89
89
|
for (const [key, value] of Object.entries(data)) {
|
|
90
|
-
if (managed.has(key) || HIDDEN_KEYS.has(key))
|
|
90
|
+
if (managed.has(key) || HIDDEN_KEYS.has(key) || key.startsWith('x-'))
|
|
91
91
|
continue;
|
|
92
92
|
if (typeof value === 'string' && !value.includes('\n'))
|
|
93
93
|
f.custom.push({ key, value });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sutrata/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React + ProseMirror screenplay editor for Sutra, with first-class support for Indic and other complex scripts.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"keywords": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"prosemirror-state": "^1.4.3",
|
|
47
47
|
"prosemirror-transform": "^1.9.0",
|
|
48
48
|
"prosemirror-view": "^1.33.0",
|
|
49
|
-
"@sutrata/parser": "0.
|
|
49
|
+
"@sutrata/parser": "0.3.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "^18.3.0",
|