@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.
@@ -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
- setText: (next: string) => void;
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, setFilePath] = useState(null);
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
- const setText = useCallback((next) => {
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
- setDirty(true);
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, textRef.current);
190
- writeRecoveryPointer(path);
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
- setText(newText);
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) stays in the document
5
- * but is hidden (`data-private`, see schema.ts). This plugin keeps the caret
6
- * out of those hidden blocks and stops Backspace/Delete from merging text
7
- * into or out of them; a range deletion that spans one still removes it.
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) stays in the document
4
- * but is hidden (`data-private`, see schema.ts). This plugin keeps the caret
5
- * out of those hidden blocks and stops Backspace/Delete from merging text
6
- * into or out of them; a range deletion that spans one still removes it.
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 || $head.depth < 1 || !isPrivateBlock($head.node(1)))
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 || $head.depth !== 1)
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);
@@ -36,7 +36,11 @@ const nodes = {
36
36
  content: '(text | hard_break)*',
37
37
  marks: '',
38
38
  attrs: { fmKey: { default: '' } },
39
- toDOM: (node) => ['div', { class: 'cs-fm-field', 'data-key': node.attrs['fmKey'] }, 0],
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
- saveDocument(path: string, content: string): Promise<void>;
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';
@@ -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
- if (update.docChanged) {
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.2.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.2.0"
49
+ "@sutrata/parser": "0.3.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^18.3.0",