@sutrata/editor 0.2.0 → 0.4.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;
@@ -19,16 +19,24 @@ export function FindReplace() {
19
19
  const [currentIndex, setCurrentIndex] = useState(0);
20
20
  const queryRef = useRef(null);
21
21
  const nextIndexAfterReplace = useRef(null);
22
- // Recompute matches whenever query/text/caseSensitive/mode changes.
22
+ // What the next highlight pass should scroll: 'none' (text edits), 'soft'
23
+ // (query typing: only if the match is off-screen), 'center' (navigation).
24
+ const pendingScroll = useRef('none');
25
+ const lastSearch = useRef({ query: '', caseSensitive: false, mode: mode });
26
+ // Recompute matches whenever query/text/caseSensitive/mode changes — but only
27
+ // while the panel is open, so a leftover query never affects the editor.
23
28
  // In formatted mode we search the PM doc's display text (not raw Sutra) so
24
29
  // offsets are correct; in source mode the CodeMirror doc *is* the raw Sutra
25
30
  // text, so raw-offset matches from find-engine map directly onto it.
26
31
  useEffect(() => {
32
+ if (!findReplaceVisible)
33
+ return;
27
34
  if (!query) {
28
35
  setMatches([]);
29
36
  setCurrentIndex(0);
30
37
  clearFindHighlights();
31
38
  clearSourceFindHighlights();
39
+ lastSearch.current = { query, caseSensitive, mode };
32
40
  return;
33
41
  }
34
42
  let found;
@@ -41,44 +49,43 @@ export function FindReplace() {
41
49
  const view = getEditorView();
42
50
  found = view ? findInPmDoc(view.state.doc, query, { caseSensitive }) : [];
43
51
  }
52
+ const prev = lastSearch.current;
53
+ const searchChanged = prev.query !== query || prev.caseSensitive !== caseSensitive || prev.mode !== mode;
54
+ lastSearch.current = { query, caseSensitive, mode };
44
55
  setMatches(found);
45
- const highlight = mode === 'source' ? setSourceFindHighlights : setFindHighlights;
46
- const scrollTo = mode === 'source' ? scrollToSourceMatch : scrollToPmMatch;
47
56
  if (nextIndexAfterReplace.current !== null) {
48
57
  const idx = Math.min(nextIndexAfterReplace.current, Math.max(0, found.length - 1));
49
58
  setCurrentIndex(idx);
50
59
  nextIndexAfterReplace.current = null;
51
- highlight(found, idx);
52
- if (found.length > 0)
53
- scrollTo(found[idx]);
60
+ pendingScroll.current = 'center';
54
61
  }
55
62
  else {
56
63
  setCurrentIndex(0);
57
- highlight(found, 0);
58
- if (found.length > 0)
59
- scrollTo(found[0]);
64
+ // Plain document edits must not move the viewport.
65
+ pendingScroll.current = searchChanged ? 'soft' : 'none';
60
66
  }
61
67
  // text in deps so we re-search after replace; view not in deps (stable ref)
62
68
  // eslint-disable-next-line react-hooks/exhaustive-deps
63
- }, [query, text, caseSensitive, mode]);
64
- // Scroll to current match and update active highlight whenever index changes.
65
- const didMount = useRef(false);
69
+ }, [query, text, caseSensitive, mode, findReplaceVisible]);
70
+ // Update the active highlight (and scroll if requested) whenever matches/index change.
66
71
  useEffect(() => {
67
- if (!didMount.current) {
68
- didMount.current = true;
72
+ if (!findReplaceVisible || !query)
69
73
  return;
70
- }
74
+ const scroll = pendingScroll.current;
75
+ pendingScroll.current = 'none';
76
+ const cur = matches[currentIndex];
71
77
  if (mode === 'source') {
72
78
  setSourceFindHighlights(matches, currentIndex);
73
- if (matches.length > 0)
74
- scrollToSourceMatch(matches[currentIndex]);
79
+ if (cur && scroll !== 'none')
80
+ scrollToSourceMatch(cur, scroll === 'center');
75
81
  }
76
82
  else {
77
83
  setFindHighlights(matches, currentIndex);
78
- if (matches.length > 0)
79
- scrollToPmMatch(matches[currentIndex]);
84
+ if (cur && scroll !== 'none')
85
+ scrollToPmMatch(cur, scroll === 'center');
80
86
  }
81
- }, [currentIndex, matches, mode]);
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [currentIndex, matches, mode, findReplaceVisible]);
82
89
  // Clear highlights when the panel closes.
83
90
  useEffect(() => {
84
91
  if (!findReplaceVisible) {
@@ -94,11 +101,13 @@ export function FindReplace() {
94
101
  const goNext = useCallback(() => {
95
102
  if (matches.length === 0)
96
103
  return;
104
+ pendingScroll.current = 'center';
97
105
  setCurrentIndex(i => (i + 1) % matches.length);
98
106
  }, [matches.length]);
99
107
  const goPrev = useCallback(() => {
100
108
  if (matches.length === 0)
101
109
  return;
110
+ pendingScroll.current = 'center';
102
111
  setCurrentIndex(i => (i - 1 + matches.length) % matches.length);
103
112
  }, [matches.length]);
104
113
  const handleReplaceOne = useCallback(() => {
@@ -24,6 +24,6 @@ export declare function findInPmDoc(doc: Node, query: string, options?: {
24
24
  * Highlighting is handled by the find-highlight plugin (decorations) — no selection
25
25
  * dispatch here so focus stays in the find input.
26
26
  */
27
- export declare function scrollToPmMatch(match: PmMatch): void;
27
+ export declare function scrollToPmMatch(match: PmMatch, center?: boolean): void;
28
28
  /** Legacy: maps a flat text-space offset to PM position. Used by tests only. */
29
29
  export declare function textOffsetToPmPos(doc: Node, textOffset: number): number | null;
@@ -52,14 +52,24 @@ export function findInPmDoc(doc, query, options = {}) {
52
52
  * Highlighting is handled by the find-highlight plugin (decorations) — no selection
53
53
  * dispatch here so focus stays in the find input.
54
54
  */
55
- export function scrollToPmMatch(match) {
55
+ export function scrollToPmMatch(match, center = true) {
56
56
  const view = getEditorView();
57
57
  if (!view)
58
58
  return;
59
59
  try {
60
60
  const domNode = view.nodeDOM(match.pmFrom);
61
61
  const el = domNode instanceof Element ? domNode : domNode?.parentElement;
62
- el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
62
+ if (!el)
63
+ return;
64
+ if (!center) {
65
+ // While typing: only scroll when the match is off-screen, and don't animate.
66
+ const r = el.getBoundingClientRect();
67
+ if (r.top >= 0 && r.bottom <= window.innerHeight)
68
+ return;
69
+ el.scrollIntoView({ block: 'center' });
70
+ return;
71
+ }
72
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
63
73
  }
64
74
  catch {
65
75
  // At doc boundary — ignore
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';
@@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback } from 'react';
3
3
  import { TextSelection } from 'prosemirror-state';
4
4
  import { useDocument } from '../context/DocumentContext';
5
5
  import { useTranslation } from '../i18n/useTranslation';
6
- import { buildSceneList } from './scene-list';
6
+ import { buildSceneList, buildSectionList } from './scene-list';
7
7
  import { getEditorView, getSourceView } from '../editor/editor-bus';
8
8
  import { schema } from '../editor/schema';
9
9
  import { EditorView as CmView } from '@codemirror/view';
@@ -198,6 +198,7 @@ export function SceneNavigator() {
198
198
  const ai = useAI();
199
199
  const canEdit = useCanEdit();
200
200
  const [scenes, setScenes] = useState([]);
201
+ const [sections, setSections] = useState([]);
201
202
  const [dragIndex, setDragIndex] = useState(null);
202
203
  const [dropIndex, setDropIndex] = useState(null);
203
204
  // New States
@@ -213,6 +214,7 @@ export function SceneNavigator() {
213
214
  // Rebuild scene list whenever text changes
214
215
  useEffect(() => {
215
216
  setScenes(buildSceneList(text));
217
+ setSections(buildSectionList(text));
216
218
  }, [text]);
217
219
  // Numbers that need renumbering (missing/duplicate/out of order; §7.4).
218
220
  // Nothing is flagged until at least one scene has a number.
@@ -389,6 +391,47 @@ export function SceneNavigator() {
389
391
  /* at doc boundary — ignore */
390
392
  }
391
393
  }, [setNavVisible]);
394
+ const handleSectionJump = useCallback((section) => {
395
+ const view = getEditorView();
396
+ if (!view) {
397
+ const cmView = getSourceView();
398
+ if (cmView) {
399
+ cmView.dispatch({
400
+ selection: { anchor: section.textOffset },
401
+ effects: CmView.scrollIntoView(section.textOffset, { y: 'start' }),
402
+ });
403
+ cmView.focus();
404
+ if (typeof window !== 'undefined' && window.innerWidth <= 640)
405
+ setNavVisible(false);
406
+ }
407
+ return;
408
+ }
409
+ try {
410
+ let pos = null;
411
+ let count = 0;
412
+ view.state.doc.forEach((node, offset) => {
413
+ if (pos !== null)
414
+ return;
415
+ if (node.type.name === 'section') {
416
+ count++;
417
+ if (count === section.index)
418
+ pos = offset;
419
+ }
420
+ });
421
+ if (pos === null)
422
+ return;
423
+ view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(pos + 1))));
424
+ view.focus();
425
+ const domNode = view.nodeDOM(pos);
426
+ const el = domNode instanceof Element ? domNode : domNode?.parentElement;
427
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
428
+ if (typeof window !== 'undefined' && window.innerWidth <= 640)
429
+ setNavVisible(false);
430
+ }
431
+ catch {
432
+ /* at doc boundary — ignore */
433
+ }
434
+ }, [setNavVisible]);
392
435
  const handleDragStart = useCallback((idx) => {
393
436
  setDragIndex(idx);
394
437
  }, []);
@@ -498,6 +541,12 @@ export function SceneNavigator() {
498
541
  const omitted = scene.status.toLowerCase() === 'omitted';
499
542
  applyTextEdit(omitted ? restoreSceneInText(current, idx) : omitSceneInText(current, idx), setText);
500
543
  }, [liveText, setText]);
544
+ const renderSection = (section) => (_jsx("div", { role: "button", tabIndex: 0, className: "cs-nav-item cs-nav-section-anchor", "aria-label": `Jump to section ${section.heading}`, onClick: () => handleSectionJump(section), onKeyDown: (e) => {
545
+ if (e.key === 'Enter' || e.key === ' ') {
546
+ e.preventDefault();
547
+ handleSectionJump(section);
548
+ }
549
+ }, children: _jsx("div", { className: "cs-nav-scene-info", children: _jsx("div", { className: "cs-nav-heading", children: section.heading || '—' }) }) }, `section:${section.index}`));
501
550
  return (_jsxs("div", { className: "cs-navigator", role: "navigation", "aria-label": "Scene Navigator", children: [_jsxs("div", { className: "cs-nav-mobile-bar", children: [_jsx("span", { className: "cs-nav-mobile-title", children: t('navigator.title') }), _jsx("button", { type: "button", className: "cs-nav-mobile-close", "aria-label": "Close navigator", onClick: () => setNavVisible(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-nav-list", children: [_jsx("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: handleFrontmatter, title: t('navigator.frontmatter'), children: _jsx("span", { className: "cs-nav-title", children: t('navigator.frontmatter') }) }), _jsxs("div", { className: "cs-nav-section-header cs-nav-scenes-header", style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("span", { className: "cs-nav-title", children: t('navigator.title') }), _jsx("span", { className: "cs-nav-count", children: scenes.length })] }), _jsxs("div", { style: { display: 'flex', gap: '4px', alignItems: 'center' }, onClick: e => e.stopPropagation(), children: [_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: () => setShowNavSynopsis(!showNavSynopsis), title: showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', "aria-label": showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', children: showNavSynopsis ? _jsx(EyeIcon, { size: 14 }) : _jsx(EyeOffIcon, { size: 14 }) }), ai && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleGenerateAllSceneMetadata, disabled: isProcessing, title: "Generate synopsis & estimate duration (all scenes)", "aria-label": "Generate synopsis & estimate duration (all scenes)", style: { opacity: isProcessing ? 0.5 : 1 }, children: _jsx(SparklesIcon, { size: 14 }) })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleRenumber, title: t('navigator.renumber'), "aria-label": t('navigator.renumber'), children: _jsx(RenumberIcon, { size: 14 }) }))] })] }), isProcessing && progressText && (_jsxs("div", { className: "cs-nav-progress", style: {
502
551
  display: 'flex',
503
552
  alignItems: 'center',
@@ -509,58 +558,65 @@ export function SceneNavigator() {
509
558
  borderBottom: '1px solid var(--cs-ui-border-light, #e2e0d6)',
510
559
  fontWeight: 500,
511
560
  }, children: [_jsx("span", { className: "cs-spinner", style: { width: '10px', height: '10px', borderWidth: '1.5px' } }), _jsx("span", { children: progressText })] })), scenes.map((scene, idx) => {
561
+ // Sections that start before this scene (and after the previous one) render as anchors above it.
562
+ const prevOffset = idx === 0 ? -1 : scenes[idx - 1].textOffset;
563
+ const leading = sections.filter(sec => sec.textOffset > prevOffset && sec.textOffset < scene.textOffset);
512
564
  const issue = numberIssues[idx] ?? null;
513
565
  const omitted = scene.status.toLowerCase() === 'omitted';
514
566
  // The id is the scene number, so a missing id is one note, not two.
515
567
  const note = issue ? issueLabel(issue) : scene.id ? null : t('navigator.missingId');
516
568
  const notes = note ? [note] : [];
517
- return (_jsxs("div", { role: "button", tabIndex: 0, "aria-label": `Jump to ${scene.heading}${notes.length ? ` (${notes.join('; ')})` : ''}`, onKeyDown: (e) => {
518
- if (e.key === 'Enter' || e.key === ' ') {
519
- e.preventDefault();
520
- handleJump(scene);
521
- }
522
- }, className: [
523
- 'cs-nav-item',
524
- // No {#id}: comments, scene-level diff, and page locking can't anchor to it.
525
- scene.id ? '' : 'cs-nav-scene-missing-id',
526
- issue ? 'cs-nav-scene-number-issue' : '',
527
- omitted ? 'cs-nav-scene-omitted' : '',
528
- dragIndex === idx ? 'cs-nav-dragging' : '',
529
- dropIndex === idx ? 'cs-nav-drop-target' : '',
530
- ]
531
- .filter(Boolean)
532
- .join(' '), draggable: canEdit, onClick: () => handleJump(scene), onDragStart: () => handleDragStart(idx), onDragOver: (e) => handleDragOver(e, idx), onDrop: (e) => handleDrop(e, idx), onDragEnd: () => {
533
- setDragIndex(null);
534
- setDropIndex(null);
535
- }, children: [_jsxs("div", { className: "cs-nav-scene-info", children: [_jsx("div", { className: "cs-nav-heading", children: scene.heading }), showNavSynopsis && (_jsxs("div", { className: "cs-nav-synopsis-row", style: { marginTop: '4px' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: !canEdit ? (scene.synopsis ? _jsx("div", { className: "cs-nav-scene-synopsis", children: scene.synopsis }) : null) : editingSceneIdx === idx ? (_jsx("input", { type: "text", className: "cs-nav-synopsis-input", value: editVal, onChange: e => setEditVal(e.target.value), onBlur: () => commitSynopsis(idx, scene), onKeyDown: e => {
536
- if (e.key === 'Enter')
537
- commitSynopsis(idx, scene);
538
- if (e.key === 'Escape')
539
- setEditingSceneIdx(null);
540
- }, autoFocus: true, onClick: e => e.stopPropagation() })) : scene.synopsis ? (_jsx("div", { className: "cs-nav-scene-synopsis", onClick: e => {
541
- e.stopPropagation();
542
- setEditingSceneIdx(idx);
543
- setEditVal(scene.synopsis);
544
- }, title: "Click to edit synopsis", children: scene.synopsis })) : (_jsx("div", { className: "cs-nav-scene-synopsis cs-nav-synopsis-placeholder", onClick: e => {
545
- e.stopPropagation();
546
- setEditingSceneIdx(idx);
547
- setEditVal('');
548
- }, role: "button", tabIndex: 0, "aria-label": "Add synopsis", onKeyDown: e => {
549
- if (e.key === 'Enter' || e.key === ' ') {
550
- e.stopPropagation();
551
- e.preventDefault();
552
- setEditingSceneIdx(idx);
553
- setEditVal('');
554
- }
555
- }, children: "+ Synopsis" })) }), ai && _jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); void handleSceneMetadata(scene); }, disabled: isProcessing, title: "Generate synopsis & estimate duration", "aria-label": "Generate synopsis & estimate duration", className: "cs-nav-action-btn cs-nav-synopsis-ai-btn", style: { color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }, children: _jsx(SparklesIcon, { size: 12 }) })] })), (scene.status || (showNavSynopsis && scene.estDuration)) && (_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }, children: [scene.status && (_jsx("span", { className: `cs-nav-status cs-status-${scene.status
556
- .toLowerCase()
557
- .replace(/\s+/g, '-')}`, children: scene.status })), showNavSynopsis && scene.estDuration && (_jsxs("span", { className: "cs-nav-status", title: "Estimated Onscreen Duration", style: {
558
- background: 'rgba(217, 119, 6, 0.1)',
559
- color: 'var(--cs-ui-accent, #c4760a)',
560
- border: '1px solid rgba(217, 119, 6, 0.25)',
561
- fontWeight: 600,
562
- }, children: ["\u23F1 ", scene.estDuration] }))] }))] }), _jsxs("div", { className: "cs-nav-scene-side", children: [scene.id ? (_jsx("div", { className: `cs-nav-scene-num${issue ? ' cs-nav-num-issue' : ''}`, title: issue ? issueLabel(issue) : undefined, children: scene.id })) : (_jsx("div", { className: "cs-nav-missing-id-icon", title: issue ? issueLabel(issue) : t('navigator.missingId'), "aria-hidden": "true", children: "\u26A0" })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn cs-nav-omit-btn", title: omitted ? t('navigator.restoreScene') : t('navigator.omitScene'), "aria-label": `${omitted ? t('navigator.restoreScene') : t('navigator.omitScene')}: ${scene.heading}`, onClick: e => { e.stopPropagation(); handleOmitToggle(scene); }, children: omitted ? _jsx(RestoreIcon, { size: 12 }) : _jsx(OmitIcon, { size: 12 }) }))] })] }, `${idx}:${scene.id ?? ''}`));
563
- }), scenes.length === 0 && (_jsx("div", { className: "cs-nav-empty", children: t('navigator.empty') })), _jsx("div", { style: { marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }, children: _jsxs("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: () => setStatsOpen(true), style: { width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx(StatsIcon, { size: 14 }), " ", _jsx("span", { className: "cs-nav-title", children: "Statistics" })] }) })] }), statsOpen && _jsx(StatisticsDialog, { onClose: () => setStatsOpen(false) })] }));
569
+ return (_jsxs(React.Fragment, { children: [leading.map(renderSection), _jsxs("div", {
570
+ // Ids can repeat until the writer renumbers (§7.4), so the index keeps keys unique.
571
+ role: "button", tabIndex: 0, "aria-label": `Jump to ${scene.heading}${notes.length ? ` (${notes.join('; ')})` : ''}`, onKeyDown: (e) => {
572
+ if (e.key === 'Enter' || e.key === ' ') {
573
+ e.preventDefault();
574
+ handleJump(scene);
575
+ }
576
+ }, className: [
577
+ 'cs-nav-item',
578
+ // No {#id}: comments, scene-level diff, and page locking can't anchor to it.
579
+ scene.id ? '' : 'cs-nav-scene-missing-id',
580
+ issue ? 'cs-nav-scene-number-issue' : '',
581
+ omitted ? 'cs-nav-scene-omitted' : '',
582
+ dragIndex === idx ? 'cs-nav-dragging' : '',
583
+ dropIndex === idx ? 'cs-nav-drop-target' : '',
584
+ ]
585
+ .filter(Boolean)
586
+ .join(' '), draggable: canEdit, onClick: () => handleJump(scene), onDragStart: () => handleDragStart(idx), onDragOver: (e) => handleDragOver(e, idx), onDrop: (e) => handleDrop(e, idx), onDragEnd: () => {
587
+ setDragIndex(null);
588
+ setDropIndex(null);
589
+ }, children: [_jsxs("div", { className: "cs-nav-scene-info", children: [_jsx("div", { className: "cs-nav-heading", children: scene.heading }), showNavSynopsis && (_jsxs("div", { className: "cs-nav-synopsis-row", style: { marginTop: '4px' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: !canEdit ? (scene.synopsis ? _jsx("div", { className: "cs-nav-scene-synopsis", children: scene.synopsis }) : null) : editingSceneIdx === idx ? (_jsx("input", { type: "text", className: "cs-nav-synopsis-input", value: editVal, onChange: e => setEditVal(e.target.value), onBlur: () => commitSynopsis(idx, scene), onKeyDown: e => {
590
+ if (e.key === 'Enter')
591
+ commitSynopsis(idx, scene);
592
+ if (e.key === 'Escape')
593
+ setEditingSceneIdx(null);
594
+ }, autoFocus: true, onClick: e => e.stopPropagation() })) : scene.synopsis ? (_jsx("div", { className: "cs-nav-scene-synopsis", onClick: e => {
595
+ e.stopPropagation();
596
+ setEditingSceneIdx(idx);
597
+ setEditVal(scene.synopsis);
598
+ }, title: "Click to edit synopsis", children: scene.synopsis })) : (_jsx("div", { className: "cs-nav-scene-synopsis cs-nav-synopsis-placeholder", onClick: e => {
599
+ e.stopPropagation();
600
+ setEditingSceneIdx(idx);
601
+ setEditVal('');
602
+ }, role: "button", tabIndex: 0, "aria-label": "Add synopsis", onKeyDown: e => {
603
+ if (e.key === 'Enter' || e.key === ' ') {
604
+ e.stopPropagation();
605
+ e.preventDefault();
606
+ setEditingSceneIdx(idx);
607
+ setEditVal('');
608
+ }
609
+ }, children: "+ Synopsis" })) }), ai && _jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); void handleSceneMetadata(scene); }, disabled: isProcessing, title: "Generate synopsis & estimate duration", "aria-label": "Generate synopsis & estimate duration", className: "cs-nav-action-btn cs-nav-synopsis-ai-btn", style: { color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }, children: _jsx(SparklesIcon, { size: 12 }) })] })), (scene.status || (showNavSynopsis && scene.estDuration)) && (_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }, children: [scene.status && (_jsx("span", { className: `cs-nav-status cs-status-${scene.status
610
+ .toLowerCase()
611
+ .replace(/\s+/g, '-')}`, children: scene.status })), showNavSynopsis && scene.estDuration && (_jsxs("span", { className: "cs-nav-status", title: "Estimated Onscreen Duration", style: {
612
+ background: 'rgba(217, 119, 6, 0.1)',
613
+ color: 'var(--cs-ui-accent, #c4760a)',
614
+ border: '1px solid rgba(217, 119, 6, 0.25)',
615
+ fontWeight: 600,
616
+ }, children: ["\u23F1 ", scene.estDuration] }))] }))] }), _jsxs("div", { className: "cs-nav-scene-side", children: [scene.id ? (_jsx("div", { className: `cs-nav-scene-num${issue ? ' cs-nav-num-issue' : ''}`, title: issue ? issueLabel(issue) : undefined, children: scene.id })) : (_jsx("div", { className: "cs-nav-missing-id-icon", title: issue ? issueLabel(issue) : t('navigator.missingId'), "aria-hidden": "true", children: "\u26A0" })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn cs-nav-omit-btn", title: omitted ? t('navigator.restoreScene') : t('navigator.omitScene'), "aria-label": `${omitted ? t('navigator.restoreScene') : t('navigator.omitScene')}: ${scene.heading}`, onClick: e => { e.stopPropagation(); handleOmitToggle(scene); }, children: omitted ? _jsx(RestoreIcon, { size: 12 }) : _jsx(OmitIcon, { size: 12 }) }))] })] })] }, `${idx}:${scene.id ?? ''}`));
617
+ }), sections
618
+ .filter(sec => scenes.length === 0 || sec.textOffset > scenes[scenes.length - 1].textOffset)
619
+ .map(renderSection), scenes.length === 0 && sections.length === 0 && (_jsx("div", { className: "cs-nav-empty", children: t('navigator.empty') })), _jsx("div", { style: { marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }, children: _jsxs("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: () => setStatsOpen(true), style: { width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx(StatsIcon, { size: 14 }), " ", _jsx("span", { className: "cs-nav-title", children: "Statistics" })] }) })] }), statsOpen && _jsx(StatisticsDialog, { onClose: () => setStatsOpen(false) })] }));
564
620
  }
565
621
  /**
566
622
  * Reorder scene blocks in the source text.
@@ -14,3 +14,10 @@ export interface SceneEntry {
14
14
  * Scenes are identified by scene heading lines (## prefix).
15
15
  */
16
16
  export declare function buildSceneList(text: string): SceneEntry[];
17
+ export interface SectionEntry {
18
+ heading: string;
19
+ index: number;
20
+ textOffset: number;
21
+ }
22
+ /** Derive the `# ` section headings (acts, sequences, …) from Sutra source text. */
23
+ export declare function buildSectionList(text: string): SectionEntry[];
@@ -68,3 +68,17 @@ export function buildSceneList(text) {
68
68
  }
69
69
  return scenes;
70
70
  }
71
+ /** Derive the `# ` section headings (acts, sequences, …) from Sutra source text. */
72
+ export function buildSectionList(text) {
73
+ const sections = [];
74
+ let offset = 0;
75
+ for (const line of text.split('\n')) {
76
+ const trimmed = line.trim();
77
+ if (trimmed === '#' || trimmed.startsWith('# ') || trimmed.startsWith('#\t')) {
78
+ const { body } = splitAttributeBlock(trimmed.slice(1).trim());
79
+ sections.push({ heading: body.trim(), index: sections.length + 1, textOffset: offset });
80
+ }
81
+ offset += line.length + 1;
82
+ }
83
+ return sections;
84
+ }
@@ -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),
@@ -11,4 +11,4 @@ export declare function setSourceFindHighlights(matches: SourceMatch[], activeIn
11
11
  /** Called by FindReplace when the panel closes — clears all source-view highlights. */
12
12
  export declare function clearSourceFindHighlights(): void;
13
13
  /** Scroll the source view so the given match is visible. */
14
- export declare function scrollToSourceMatch(match: SourceMatch): void;
14
+ export declare function scrollToSourceMatch(match: SourceMatch, center?: boolean): void;
@@ -39,9 +39,10 @@ export function clearSourceFindHighlights() {
39
39
  setSourceFindHighlights([], 0);
40
40
  }
41
41
  /** Scroll the source view so the given match is visible. */
42
- export function scrollToSourceMatch(match) {
42
+ export function scrollToSourceMatch(match, center = true) {
43
43
  const view = getSourceView();
44
44
  if (!view)
45
45
  return;
46
- view.dispatch({ effects: EditorView.scrollIntoView(match.start, { y: 'center' }) });
46
+ // 'nearest' only scrolls when the match is off-screen (used while typing).
47
+ view.dispatch({ effects: EditorView.scrollIntoView(match.start, { y: center ? 'center' : 'nearest' }) });
47
48
  }
@@ -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
 
@@ -3116,3 +3117,15 @@ html, body, #root { height: 100%; margin: 0; padding: 0; overflow: hidden; }
3116
3117
  color: var(--cs-ui-warning, #c2410c); background: var(--cs-ui-warning-subtle, #fff7ed);
3117
3118
  user-select: none; vertical-align: middle;
3118
3119
  }
3120
+
3121
+ /* Section headings (acts, sequences) shown as anchors in the navigator. */
3122
+ .cs-nav-section-anchor {
3123
+ background: var(--cs-ui-bg-panel-subtle, #f4f2ea);
3124
+ border-bottom: 1px solid var(--cs-ui-border-light, #e2e0d6);
3125
+ }
3126
+ .cs-nav-section-anchor .cs-nav-heading {
3127
+ font-weight: 700;
3128
+ text-transform: uppercase;
3129
+ font-size: 11px;
3130
+ letter-spacing: 0.04em;
3131
+ }
@@ -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.4.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",