@sutrata/editor 0.3.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,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
@@ -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
+ }
@@ -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
  }
@@ -3117,3 +3117,15 @@ html, body, #root { height: 100%; margin: 0; padding: 0; overflow: hidden; }
3117
3117
  color: var(--cs-ui-warning, #c2410c); background: var(--cs-ui-warning-subtle, #fff7ed);
3118
3118
  user-select: none; vertical-align: middle;
3119
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sutrata/editor",
3
- "version": "0.3.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": [