@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.
- package/dist/findreplace/FindReplace.js +29 -20
- package/dist/findreplace/scroll-to-match.d.ts +1 -1
- package/dist/findreplace/scroll-to-match.js +12 -2
- package/dist/navigator/SceneNavigator.js +104 -48
- package/dist/navigator/scene-list.d.ts +7 -0
- package/dist/navigator/scene-list.js +14 -0
- package/dist/source/find-highlight-source.d.ts +1 -1
- package/dist/source/find-highlight-source.js +3 -2
- package/dist/styles/screenplay.css +12 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
|
|
52
|
-
if (found.length > 0)
|
|
53
|
-
scrollTo(found[idx]);
|
|
60
|
+
pendingScroll.current = 'center';
|
|
54
61
|
}
|
|
55
62
|
else {
|
|
56
63
|
setCurrentIndex(0);
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
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 (!
|
|
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 (
|
|
74
|
-
scrollToSourceMatch(
|
|
79
|
+
if (cur && scroll !== 'none')
|
|
80
|
+
scrollToSourceMatch(cur, scroll === 'center');
|
|
75
81
|
}
|
|
76
82
|
else {
|
|
77
83
|
setFindHighlights(matches, currentIndex);
|
|
78
|
-
if (
|
|
79
|
-
scrollToPmMatch(
|
|
84
|
+
if (cur && scroll !== 'none')
|
|
85
|
+
scrollToPmMatch(cur, scroll === 'center');
|
|
80
86
|
}
|
|
81
|
-
|
|
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
|
|
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(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
e
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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