@sutrata/editor 0.1.2 → 0.2.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/ai/VoiceConfirmDialog.d.ts +1 -0
- package/dist/ai/VoiceConfirmDialog.js +6 -6
- package/dist/ai/VoiceToolbar.d.ts +1 -0
- package/dist/ai/VoiceToolbar.js +22 -16
- package/dist/ai/clean-response.d.ts +7 -0
- package/dist/ai/clean-response.js +58 -0
- package/dist/ai/format-transcript.d.ts +3 -0
- package/dist/ai/format-transcript.js +7 -0
- package/dist/ai/prompts.d.ts +8 -1
- package/dist/ai/prompts.js +34 -7
- package/dist/autocomplete/suggestions.js +1 -1
- package/dist/context/DocumentContext.d.ts +28 -4
- package/dist/context/DocumentContext.js +67 -37
- package/dist/editor/EditorView.js +32 -4
- package/dist/editor/SelectionMenu.js +5 -3
- package/dist/editor/external-decorations-plugin.d.ts +23 -0
- package/dist/editor/external-decorations-plugin.js +151 -0
- package/dist/editor/omitted-scene-plugin.d.ts +9 -0
- package/dist/editor/omitted-scene-plugin.js +44 -0
- package/dist/editor/plugins.js +4 -0
- package/dist/editor/private-metadata-plugin.d.ts +10 -0
- package/dist/editor/private-metadata-plugin.js +60 -0
- package/dist/editor/prosemirror-to-sutra.js +179 -74
- package/dist/editor/scene-heading-view.js +2 -0
- package/dist/editor/schema.js +69 -12
- package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
- package/dist/editor/sutra-to-prosemirror.js +32 -34
- package/dist/extensions/ai-provider.d.ts +44 -0
- package/dist/extensions/ai-provider.js +27 -0
- package/dist/extensions/collab.d.ts +21 -0
- package/dist/extensions/collab.js +6 -0
- package/dist/extensions/command-registry.d.ts +49 -0
- package/dist/extensions/command-registry.js +28 -0
- package/dist/extensions/decorations.d.ts +48 -0
- package/dist/extensions/decorations.js +6 -0
- package/dist/extensions/export-registry.d.ts +59 -0
- package/dist/extensions/export-registry.js +10 -0
- package/dist/extensions/panel-registry.d.ts +30 -0
- package/dist/extensions/panel-registry.js +16 -0
- package/dist/extensions/registries.d.ts +15 -0
- package/dist/extensions/registries.js +12 -0
- package/dist/extensions/registry-store.d.ts +26 -0
- package/dist/extensions/registry-store.js +35 -0
- package/dist/extensions/session.d.ts +29 -0
- package/dist/extensions/session.js +20 -0
- package/dist/extensions/speech-provider.d.ts +35 -0
- package/dist/extensions/speech-provider.js +17 -0
- package/dist/file/builtin-exporters.d.ts +9 -0
- package/dist/file/builtin-exporters.js +72 -0
- package/dist/file/docx-exporter.js +7 -4
- package/dist/file/docx-importer.d.ts +9 -0
- package/dist/file/docx-importer.js +47 -19
- package/dist/file/scene-number.d.ts +5 -0
- package/dist/file/scene-number.js +8 -0
- package/dist/file/workflow-reports.js +13 -7
- package/dist/findreplace/FindReplace.js +5 -2
- package/dist/import/AIImportDialog.d.ts +11 -0
- package/dist/import/AIImportDialog.js +112 -0
- package/dist/import/ai-import.d.ts +51 -0
- package/dist/import/ai-import.js +200 -0
- package/dist/import/apply-import.d.ts +7 -0
- package/dist/import/apply-import.js +35 -0
- package/dist/import/extract.d.ts +17 -0
- package/dist/import/extract.js +18 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +7 -0
- package/dist/locales/bn.json +8 -0
- package/dist/locales/en.json +8 -0
- package/dist/locales/gu.json +8 -0
- package/dist/locales/hi.json +8 -0
- package/dist/locales/kn.json +8 -0
- package/dist/locales/ml.json +8 -0
- package/dist/locales/mr.json +8 -0
- package/dist/locales/or.json +8 -0
- package/dist/locales/pa.json +8 -0
- package/dist/locales/si.json +8 -0
- package/dist/locales/ta.json +8 -0
- package/dist/locales/te.json +8 -0
- package/dist/navigator/SceneNavigator.js +125 -131
- package/dist/navigator/StatisticsDialog.js +5 -1
- package/dist/navigator/scene-edits.d.ts +18 -0
- package/dist/navigator/scene-edits.js +143 -0
- package/dist/navigator/scene-list.js +4 -4
- package/dist/navigator/scene-numbering.d.ts +37 -0
- package/dist/navigator/scene-numbering.js +88 -0
- package/dist/settings/SettingsDialog.js +8 -120
- package/dist/shell/AppBar.js +21 -2
- package/dist/shell/AppShell.js +18 -4
- package/dist/shell/ElementToolbar.d.ts +5 -0
- package/dist/shell/ElementToolbar.js +35 -25
- package/dist/shell/LanguageStatus.js +6 -0
- package/dist/shell/StatusBar.js +11 -6
- package/dist/shell/builtin-commands.d.ts +10 -0
- package/dist/shell/builtin-commands.js +43 -0
- package/dist/shell/icons.d.ts +6 -0
- package/dist/shell/icons.js +6 -0
- package/dist/shell/use-commands.d.ts +7 -0
- package/dist/shell/use-commands.js +24 -0
- package/dist/shell/use-panel-context.d.ts +3 -0
- package/dist/shell/use-panel-context.js +39 -0
- package/dist/source/SourceView.js +18 -1
- package/dist/styles/screenplay.css +113 -1
- package/dist/titlepage/ExportDialog.d.ts +5 -0
- package/dist/titlepage/ExportDialog.js +59 -96
- package/package.json +4 -4
- package/dist/ai/OnboardingDialog.d.ts +0 -6
- package/dist/ai/OnboardingDialog.js +0 -115
- package/dist/ai/ai-client.d.ts +0 -71
- package/dist/ai/ai-client.js +0 -494
- package/dist/ai/generated-providers.json +0 -3696
- package/dist/ai/voice-service.d.ts +0 -15
- package/dist/ai/voice-service.js +0 -81
- package/dist/editor/character-view.d.ts +0 -35
- package/dist/editor/character-view.js +0 -112
- package/dist/editor/transliterate-input.d.ts +0 -10
- package/dist/editor/transliterate-input.js +0 -88
- package/dist/extensions/types.d.ts +0 -73
- package/dist/extensions/types.js +0 -12
|
@@ -1,94 +1,196 @@
|
|
|
1
|
+
import { parseFrontmatter, formatAttributeBlock } from '@sutrata/parser';
|
|
1
2
|
import { schema } from './schema';
|
|
3
|
+
import { frontmatterFields } from './sutra-to-prosemirror';
|
|
4
|
+
/** Plain text of a block, with hard_break nodes as '\n'. */
|
|
2
5
|
function nodeText(node) {
|
|
3
|
-
return node.textContent;
|
|
4
|
-
}
|
|
5
|
-
function inlineToMarkdown(node) {
|
|
6
6
|
let result = '';
|
|
7
7
|
node.forEach(child => {
|
|
8
|
-
if (child.isText)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
else if (child.marks.find(m => m.type === schema.marks['italic']))
|
|
13
|
-
text = `*${text}*`;
|
|
14
|
-
else if (child.marks.find(m => m.type === schema.marks['underline']))
|
|
15
|
-
text = `_${text}_`;
|
|
16
|
-
else if (child.marks.find(m => m.type === schema.marks['note_mark']))
|
|
17
|
-
text = `[[${text}]]`;
|
|
18
|
-
result += text;
|
|
19
|
-
}
|
|
8
|
+
if (child.isText)
|
|
9
|
+
result += child.text ?? '';
|
|
10
|
+
else if (child.type === schema.nodes['hard_break'])
|
|
11
|
+
result += '\n';
|
|
20
12
|
});
|
|
21
13
|
return result;
|
|
22
14
|
}
|
|
15
|
+
const DELIMITERS = {
|
|
16
|
+
bold: ['**', '**'],
|
|
17
|
+
italic: ['*', '*'],
|
|
18
|
+
underline: ['_', '_'],
|
|
19
|
+
note_mark: ['[[', ']]'],
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Inline content → Sutra emphasis markup. Marks are opened and closed as a
|
|
23
|
+
* stack (ProseMirror orders them bold → italic → underline → note), so
|
|
24
|
+
* overlapping marks nest: bold+italic text becomes `***text***`.
|
|
25
|
+
*
|
|
26
|
+
* With `closeAtNewline`, every line is closed off on its own. Lyrics need
|
|
27
|
+
* that (the parser reads each `~` line separately); action does not (its
|
|
28
|
+
* whole paragraph is parsed as one run, so emphasis may span lines).
|
|
29
|
+
*/
|
|
30
|
+
function inlineToMarkdown(node, closeAtNewline = false) {
|
|
31
|
+
let out = '';
|
|
32
|
+
let open = [];
|
|
33
|
+
const closeTo = (depth) => {
|
|
34
|
+
while (open.length > depth)
|
|
35
|
+
out += DELIMITERS[open.pop().type.name]?.[1] ?? '';
|
|
36
|
+
};
|
|
37
|
+
const write = (text, marks) => {
|
|
38
|
+
const wanted = marks.filter(m => DELIMITERS[m.type.name]);
|
|
39
|
+
let common = 0;
|
|
40
|
+
while (common < open.length && common < wanted.length && open[common].eq(wanted[common]))
|
|
41
|
+
common++;
|
|
42
|
+
closeTo(common);
|
|
43
|
+
for (const m of wanted.slice(common)) {
|
|
44
|
+
out += DELIMITERS[m.type.name][0];
|
|
45
|
+
open.push(m);
|
|
46
|
+
}
|
|
47
|
+
out += text;
|
|
48
|
+
};
|
|
49
|
+
node.forEach(child => {
|
|
50
|
+
if (child.type === schema.nodes['hard_break']) {
|
|
51
|
+
closeTo(0);
|
|
52
|
+
out += '\n';
|
|
53
|
+
}
|
|
54
|
+
else if (child.isText) {
|
|
55
|
+
const text = child.text ?? '';
|
|
56
|
+
if (!closeAtNewline) {
|
|
57
|
+
write(text, child.marks);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
text.split('\n').forEach((line, i) => {
|
|
61
|
+
if (i > 0) {
|
|
62
|
+
closeTo(0);
|
|
63
|
+
out += '\n';
|
|
64
|
+
}
|
|
65
|
+
if (line)
|
|
66
|
+
write(line, child.marks);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
closeTo(0);
|
|
71
|
+
open = [];
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function attributeBlock(node) {
|
|
75
|
+
return formatAttributeBlock(node.attrs['id'] ?? null, node.attrs['attrs'] ?? []);
|
|
76
|
+
}
|
|
77
|
+
/** `& key: value`, or `& key:` followed by ` - item` lines for a list value. */
|
|
78
|
+
function metadataLines(node) {
|
|
79
|
+
const key = node.attrs['metaKey'];
|
|
80
|
+
const value = nodeText(node);
|
|
81
|
+
if (node.attrs['list']) {
|
|
82
|
+
const items = value.split('\n');
|
|
83
|
+
return [`& ${key}:`, ...items.map(item => (item.trim() ? ` - ${item.trim()}` : ' -'))];
|
|
84
|
+
}
|
|
85
|
+
return [`& ${key}: ${value}`.trimEnd()];
|
|
86
|
+
}
|
|
23
87
|
/**
|
|
24
88
|
* Extract the text value of a frontmatter_field node, converting hard_break
|
|
25
89
|
* nodes back to '\n' so multi-line values round-trip correctly.
|
|
26
90
|
*/
|
|
27
91
|
function fieldText(node) {
|
|
28
|
-
|
|
29
|
-
node.forEach(child => {
|
|
30
|
-
if (child.isText)
|
|
31
|
-
result += child.text ?? '';
|
|
32
|
-
else if (child.type === schema.nodes['hard_break'])
|
|
33
|
-
result += '\n';
|
|
34
|
-
});
|
|
35
|
-
return result.trim();
|
|
92
|
+
return nodeText(node).trim();
|
|
36
93
|
}
|
|
37
94
|
/**
|
|
38
|
-
* title_page
|
|
39
|
-
* reassembled into one-level nested maps
|
|
40
|
-
* parser's minimal-YAML frontmatter format). Fields with empty key AND
|
|
41
|
-
* empty value are skipped; returns '' if nothing remains.
|
|
42
|
-
* Multi-line values are serialized as YAML literal block scalars (|).
|
|
95
|
+
* title_page fields → one value per top-level key. Dotted keys ("title.hi")
|
|
96
|
+
* are reassembled into one-level nested maps. Fields without a key are skipped.
|
|
43
97
|
*/
|
|
44
|
-
function
|
|
98
|
+
function groupFields(fields) {
|
|
45
99
|
const data = new Map();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return;
|
|
49
|
-
const key = (field.attrs['fmKey'] ?? '').trim();
|
|
50
|
-
const value = fieldText(field);
|
|
100
|
+
for (const [rawKey, value] of fields) {
|
|
101
|
+
const key = rawKey.trim();
|
|
51
102
|
if (!key)
|
|
52
|
-
|
|
103
|
+
continue;
|
|
53
104
|
const dot = key.indexOf('.');
|
|
54
105
|
if (dot > 0 && dot < key.length - 1) {
|
|
55
106
|
const parent = key.slice(0, dot);
|
|
56
|
-
const sub = key.slice(dot + 1);
|
|
57
107
|
const existing = data.get(parent);
|
|
58
108
|
const map = existing !== undefined && typeof existing === 'object' ? existing : {};
|
|
59
|
-
map[
|
|
109
|
+
map[key.slice(dot + 1)] = value;
|
|
60
110
|
data.set(parent, map);
|
|
61
111
|
}
|
|
62
112
|
else {
|
|
63
113
|
data.set(key, value);
|
|
64
114
|
}
|
|
115
|
+
}
|
|
116
|
+
return data;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* YAML lines for one key, in the subset the parser's minimal YAML reader
|
|
120
|
+
* understands: plain scalars, `|` literal blocks for multi-line values, and
|
|
121
|
+
* one level of nested map (2-space indent).
|
|
122
|
+
*/
|
|
123
|
+
function keyLines(key, value) {
|
|
124
|
+
if (typeof value !== 'string') {
|
|
125
|
+
return [`${key}:`, ...Object.entries(value).map(([sub, sv]) => ` ${sub}: ${sv}`)];
|
|
126
|
+
}
|
|
127
|
+
if (value === '')
|
|
128
|
+
return [`${key}:`];
|
|
129
|
+
if (value.includes('\n'))
|
|
130
|
+
return [`${key}: |`, ...value.split('\n').map(ln => ` ${ln}`)];
|
|
131
|
+
return [`${key}: ${value}`];
|
|
132
|
+
}
|
|
133
|
+
function sameValue(a, b) {
|
|
134
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
135
|
+
}
|
|
136
|
+
const YAML_KEY = /^([a-zA-Z0-9_-]+):/;
|
|
137
|
+
/**
|
|
138
|
+
* title_page node → frontmatter text block (ending in a blank line), or ''.
|
|
139
|
+
*
|
|
140
|
+
* When the node was built from frontmatter source (its `raw` attr), that
|
|
141
|
+
* source is kept: unchanged keys keep their original lines — including
|
|
142
|
+
* comments, lists, quoting and order the fields cannot represent — and only
|
|
143
|
+
* keys whose value changed are rewritten, removed, or appended.
|
|
144
|
+
*/
|
|
145
|
+
function serializeTitlePage(node) {
|
|
146
|
+
const fields = [];
|
|
147
|
+
node.forEach(field => {
|
|
148
|
+
if (field.type.name === 'frontmatter_field')
|
|
149
|
+
fields.push([field.attrs['fmKey'] ?? '', fieldText(field)]);
|
|
65
150
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
151
|
+
const current = groupFields(fields);
|
|
152
|
+
const raw = node.attrs['raw'];
|
|
153
|
+
const parsed = raw ? parseFrontmatter(raw.endsWith('\n') ? raw : raw + '\n') : null;
|
|
154
|
+
if (!raw || !parsed) {
|
|
155
|
+
if (current.size === 0)
|
|
156
|
+
return '';
|
|
157
|
+
return ['---', ...[...current].flatMap(([k, v]) => keyLines(k, v)), '---'].join('\n') + '\n\n';
|
|
158
|
+
}
|
|
159
|
+
const original = groupFields(frontmatterFields(parsed.data).map(([k, v]) => [k, v.trim()]));
|
|
160
|
+
const lines = raw.replace(/\n$/, '').split('\n');
|
|
161
|
+
const body = lines.slice(1, -1); // between the --- fences
|
|
162
|
+
// Group raw lines into entries: a key line plus its continuation lines.
|
|
163
|
+
const entries = [];
|
|
164
|
+
for (const line of body) {
|
|
165
|
+
const m = YAML_KEY.exec(line);
|
|
166
|
+
if (m)
|
|
167
|
+
entries.push({ key: m[1], lines: [line] });
|
|
168
|
+
else if (entries.length > 0)
|
|
169
|
+
entries[entries.length - 1].lines.push(line);
|
|
170
|
+
else
|
|
171
|
+
entries.push({ key: null, lines: [line] });
|
|
172
|
+
}
|
|
173
|
+
const out = [];
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const key = entry.key;
|
|
177
|
+
if (key === null || !original.has(key) || sameValue(original.get(key), current.get(key))) {
|
|
178
|
+
if (key !== null && original.has(key) && !current.has(key))
|
|
179
|
+
continue; // removed
|
|
180
|
+
out.push(...entry.lines);
|
|
83
181
|
}
|
|
84
|
-
else {
|
|
85
|
-
|
|
86
|
-
for (const [sub, sv] of Object.entries(value))
|
|
87
|
-
lines.push(` ${sub}: ${sv}`);
|
|
182
|
+
else if (current.has(key)) {
|
|
183
|
+
out.push(...keyLines(key, current.get(key)));
|
|
88
184
|
}
|
|
185
|
+
if (key !== null)
|
|
186
|
+
seen.add(key);
|
|
89
187
|
}
|
|
90
|
-
|
|
91
|
-
|
|
188
|
+
for (const [key, value] of current)
|
|
189
|
+
if (!seen.has(key))
|
|
190
|
+
out.push(...keyLines(key, value));
|
|
191
|
+
if (!out.some(l => l.trim() !== ''))
|
|
192
|
+
return '';
|
|
193
|
+
return ['---', ...out, '---'].join('\n') + '\n\n';
|
|
92
194
|
}
|
|
93
195
|
export function prosemirrorToSutra(doc) {
|
|
94
196
|
// Collect all top-level nodes into an array first
|
|
@@ -105,11 +207,12 @@ export function prosemirrorToSutra(doc) {
|
|
|
105
207
|
const node = pmNodes[i];
|
|
106
208
|
const type = node.type.name;
|
|
107
209
|
if (type === 'character') {
|
|
108
|
-
// Group: character cue + following
|
|
210
|
+
// Group: character cue + following registry metadata and
|
|
211
|
+
// dialogue/parenthetical lines (no blank line between them).
|
|
109
212
|
// Name, extension and dual-marker are all just typed text now — see
|
|
110
213
|
// character-decoration-plugin.ts — so the node's own text is the
|
|
111
214
|
// whole cue line, verbatim.
|
|
112
|
-
const lines = [`@${nodeText(node)}`];
|
|
215
|
+
const lines = [`@${nodeText(node)}${attributeBlock(node)}`];
|
|
113
216
|
i++;
|
|
114
217
|
while (i < pmNodes.length) {
|
|
115
218
|
const next = pmNodes[i];
|
|
@@ -122,6 +225,11 @@ export function prosemirrorToSutra(doc) {
|
|
|
122
225
|
lines.push(`( ${nodeText(next)} )`);
|
|
123
226
|
i++;
|
|
124
227
|
}
|
|
228
|
+
else if (nextType === 'scene_metadata') {
|
|
229
|
+
// Only produced after a cue for {#characters} registry entries (§8.2).
|
|
230
|
+
lines.push(...metadataLines(next));
|
|
231
|
+
i++;
|
|
232
|
+
}
|
|
125
233
|
else {
|
|
126
234
|
break;
|
|
127
235
|
}
|
|
@@ -130,14 +238,12 @@ export function prosemirrorToSutra(doc) {
|
|
|
130
238
|
continue;
|
|
131
239
|
}
|
|
132
240
|
if (type === 'scene_heading') {
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
const headingLines = [`## ${nodeText(node)}${id}`];
|
|
241
|
+
// Group heading + metadata lines with single newlines (no blank line
|
|
242
|
+
// between them), so the parser recognises them as one heading block.
|
|
243
|
+
const headingLines = [`## ${nodeText(node)}${attributeBlock(node)}`];
|
|
137
244
|
i++;
|
|
138
245
|
while (i < pmNodes.length && pmNodes[i].type.name === 'scene_metadata') {
|
|
139
|
-
|
|
140
|
-
headingLines.push(`& ${m.attrs['metaKey']}: ${nodeText(m)}`);
|
|
246
|
+
headingLines.push(...metadataLines(pmNodes[i]));
|
|
141
247
|
i++;
|
|
142
248
|
}
|
|
143
249
|
blocks.push(headingLines.join('\n'));
|
|
@@ -145,13 +251,12 @@ export function prosemirrorToSutra(doc) {
|
|
|
145
251
|
}
|
|
146
252
|
if (type === 'scene_metadata') {
|
|
147
253
|
// Orphan metadata (outside a heading group) — emit as-is
|
|
148
|
-
blocks.push(
|
|
254
|
+
blocks.push(metadataLines(node).join('\n'));
|
|
149
255
|
i++;
|
|
150
256
|
continue;
|
|
151
257
|
}
|
|
152
258
|
if (type === 'section') {
|
|
153
|
-
|
|
154
|
-
blocks.push(`# ${nodeText(node)}${id}`);
|
|
259
|
+
blocks.push(`# ${nodeText(node)}${attributeBlock(node)}`);
|
|
155
260
|
i++;
|
|
156
261
|
continue;
|
|
157
262
|
}
|
|
@@ -173,17 +278,17 @@ export function prosemirrorToSutra(doc) {
|
|
|
173
278
|
continue;
|
|
174
279
|
}
|
|
175
280
|
if (type === 'transition') {
|
|
176
|
-
blocks.push(`>> ${
|
|
281
|
+
blocks.push(nodeText(node).split('\n').map(l => `>> ${l}`).join('\n'));
|
|
177
282
|
i++;
|
|
178
283
|
continue;
|
|
179
284
|
}
|
|
180
285
|
if (type === 'centered') {
|
|
181
|
-
blocks.push(`>> ${
|
|
286
|
+
blocks.push(nodeText(node).split('\n').map(l => `>> ${l} <<`).join('\n'));
|
|
182
287
|
i++;
|
|
183
288
|
continue;
|
|
184
289
|
}
|
|
185
290
|
if (type === 'lyrics') {
|
|
186
|
-
blocks.push(`~ ${
|
|
291
|
+
blocks.push(inlineToMarkdown(node, true).split('\n').map(l => `~ ${l}`).join('\n'));
|
|
187
292
|
i++;
|
|
188
293
|
continue;
|
|
189
294
|
}
|
|
@@ -34,6 +34,7 @@ export class SceneHeadingView {
|
|
|
34
34
|
this.input.value = node.attrs['id'] ?? '';
|
|
35
35
|
this.input.setAttribute('aria-label', 'Scene number');
|
|
36
36
|
this.input.spellcheck = false;
|
|
37
|
+
this.input.readOnly = !view.editable;
|
|
37
38
|
this.input.addEventListener('keydown', this.onInputKeydown);
|
|
38
39
|
this.input.addEventListener('blur', this.onInputBlur);
|
|
39
40
|
// Prevent PM from handling clicks inside the input
|
|
@@ -116,6 +117,7 @@ export class SceneHeadingView {
|
|
|
116
117
|
this.dom.dataset['sceneId'] = id;
|
|
117
118
|
else
|
|
118
119
|
delete this.dom.dataset['sceneId'];
|
|
120
|
+
this.input.readOnly = !this.pmView.editable;
|
|
119
121
|
// Only update input if it doesn't have focus (avoid clobbering in-progress edits)
|
|
120
122
|
if (document.activeElement !== this.input) {
|
|
121
123
|
this.input.value = id;
|
package/dist/editor/schema.js
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { Schema } from 'prosemirror-model';
|
|
2
|
+
/**
|
|
3
|
+
* Attributes (format spec §10) other than the id, kept in source order as
|
|
4
|
+
* `{ key, value }` pairs so unknown ones round-trip. Serialized into the DOM
|
|
5
|
+
* as JSON so copy/paste inside the editor keeps them.
|
|
6
|
+
*/
|
|
7
|
+
function readAttrs(dom) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(dom.dataset['attrs'] ?? '[]');
|
|
10
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function withAttrs(dom, node) {
|
|
17
|
+
const attrs = node.attrs['attrs'];
|
|
18
|
+
if (attrs.length > 0)
|
|
19
|
+
dom['data-attrs'] = JSON.stringify(attrs);
|
|
20
|
+
return dom;
|
|
21
|
+
}
|
|
2
22
|
const nodes = {
|
|
3
23
|
doc: { content: 'title_page? block+' },
|
|
4
24
|
// Frontmatter rendered as an editable card at the top of the document.
|
|
5
25
|
// Only valid as the first child of doc (not in the block group).
|
|
6
26
|
title_page: {
|
|
7
27
|
content: 'frontmatter_field+',
|
|
28
|
+
// The frontmatter source this node was built from. prosemirror-to-sutra
|
|
29
|
+
// re-emits it verbatim (or edits only the changed keys), so comments,
|
|
30
|
+
// lists, quoting and key order the fields cannot represent survive.
|
|
31
|
+
attrs: { raw: { default: null } },
|
|
8
32
|
toDOM: () => ['div', { class: 'cs-title-page-block' }, 0],
|
|
9
33
|
parseDOM: [{ tag: 'div.cs-title-page-block' }],
|
|
10
34
|
},
|
|
@@ -18,14 +42,17 @@ const nodes = {
|
|
|
18
42
|
scene_heading: {
|
|
19
43
|
group: 'block',
|
|
20
44
|
content: 'inline*',
|
|
21
|
-
attrs: { id: { default: null } },
|
|
45
|
+
attrs: { id: { default: null }, attrs: { default: [] } },
|
|
22
46
|
toDOM: (node) => {
|
|
23
47
|
const attrs = { class: 'cs-scene-heading' };
|
|
24
48
|
if (node.attrs['id'])
|
|
25
49
|
attrs['data-scene-id'] = node.attrs['id'];
|
|
26
|
-
return ['div', attrs, 0];
|
|
50
|
+
return ['div', withAttrs(attrs, node), 0];
|
|
27
51
|
},
|
|
28
|
-
parseDOM: [{
|
|
52
|
+
parseDOM: [{
|
|
53
|
+
tag: 'div.cs-scene-heading',
|
|
54
|
+
getAttrs: (dom) => ({ id: dom.dataset['sceneId'] ?? null, attrs: readAttrs(dom) }),
|
|
55
|
+
}],
|
|
29
56
|
},
|
|
30
57
|
action: {
|
|
31
58
|
group: 'block',
|
|
@@ -36,8 +63,17 @@ const nodes = {
|
|
|
36
63
|
character: {
|
|
37
64
|
group: 'block',
|
|
38
65
|
content: 'inline*',
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
attrs: { id: { default: null }, attrs: { default: [] } },
|
|
67
|
+
toDOM: (node) => {
|
|
68
|
+
const attrs = { class: 'cs-character' };
|
|
69
|
+
if (node.attrs['id'])
|
|
70
|
+
attrs['data-id'] = node.attrs['id'];
|
|
71
|
+
return ['div', withAttrs(attrs, node), 0];
|
|
72
|
+
},
|
|
73
|
+
parseDOM: [{
|
|
74
|
+
tag: 'div.cs-character',
|
|
75
|
+
getAttrs: (dom) => ({ id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }),
|
|
76
|
+
}],
|
|
41
77
|
},
|
|
42
78
|
dialogue: {
|
|
43
79
|
group: 'block',
|
|
@@ -72,9 +108,25 @@ const nodes = {
|
|
|
72
108
|
scene_metadata: {
|
|
73
109
|
group: 'block',
|
|
74
110
|
content: 'inline*',
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
// list: the value is a `- item` list (§7.1), one item per line of content.
|
|
112
|
+
attrs: { metaKey: { default: '' }, list: { default: false } },
|
|
113
|
+
toDOM: (node) => {
|
|
114
|
+
const attrs = { class: 'cs-scene-metadata', 'data-key': node.attrs['metaKey'] };
|
|
115
|
+
if (node.attrs['list'])
|
|
116
|
+
attrs['data-list'] = 'true';
|
|
117
|
+
// `x-` keys belong to a tool (format spec §7.1): kept, but not shown
|
|
118
|
+
// (private-metadata-plugin keeps the caret out).
|
|
119
|
+
if (node.attrs['metaKey'].startsWith('x-'))
|
|
120
|
+
attrs['data-private'] = 'true';
|
|
121
|
+
return ['div', attrs, 0];
|
|
122
|
+
},
|
|
123
|
+
parseDOM: [{
|
|
124
|
+
tag: 'div.cs-scene-metadata',
|
|
125
|
+
getAttrs: (dom) => ({
|
|
126
|
+
metaKey: dom.dataset['key'] ?? '',
|
|
127
|
+
list: dom.dataset['list'] === 'true',
|
|
128
|
+
}),
|
|
129
|
+
}],
|
|
78
130
|
},
|
|
79
131
|
note: {
|
|
80
132
|
group: 'block',
|
|
@@ -96,11 +148,16 @@ const nodes = {
|
|
|
96
148
|
section: {
|
|
97
149
|
group: 'block',
|
|
98
150
|
content: 'inline*',
|
|
99
|
-
attrs: { level: { default: 1 }, id: { default: null } },
|
|
100
|
-
toDOM: (node) =>
|
|
151
|
+
attrs: { level: { default: 1 }, id: { default: null }, attrs: { default: [] } },
|
|
152
|
+
toDOM: (node) => {
|
|
153
|
+
const attrs = { class: 'cs-section' };
|
|
154
|
+
if (node.attrs['id'])
|
|
155
|
+
attrs['data-id'] = node.attrs['id'];
|
|
156
|
+
return [`h${node.attrs['level'] + 1}`, withAttrs(attrs, node), 0];
|
|
157
|
+
},
|
|
101
158
|
parseDOM: [
|
|
102
|
-
{ tag: 'h2.cs-section', getAttrs: () => ({ level: 1 }) },
|
|
103
|
-
{ tag: 'h3.cs-section', getAttrs: () => ({ level: 2 }) },
|
|
159
|
+
{ tag: 'h2.cs-section', getAttrs: (dom) => ({ level: 1, id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }) },
|
|
160
|
+
{ tag: 'h3.cs-section', getAttrs: (dom) => ({ level: 2, id: dom.dataset['id'] ?? null, attrs: readAttrs(dom) }) },
|
|
104
161
|
],
|
|
105
162
|
},
|
|
106
163
|
hard_break: {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { Node as PmNode } from 'prosemirror-model';
|
|
2
2
|
import type { DocumentNode } from '@sutrata/parser';
|
|
3
|
+
/** The (fmKey, value) pairs a title_page shows for parsed frontmatter data. */
|
|
4
|
+
export declare function frontmatterFields(data: Record<string, unknown>): [string, string][];
|
|
3
5
|
export declare function sutraToProsemirror(doc: DocumentNode): PmNode;
|
|
@@ -13,7 +13,8 @@ function spansToNodes(spans) {
|
|
|
13
13
|
const markType = schema.marks[span.type];
|
|
14
14
|
if (!markType || inner.length === 0)
|
|
15
15
|
return inner;
|
|
16
|
-
|
|
16
|
+
// addToSet, not a replacement: nested emphasis keeps every mark.
|
|
17
|
+
return inner.map(n => n.mark(markType.create().addToSet(n.marks)));
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
function textNodes(text) {
|
|
@@ -28,6 +29,13 @@ function textNodes(text) {
|
|
|
28
29
|
}
|
|
29
30
|
return nodes;
|
|
30
31
|
}
|
|
32
|
+
/** `& key: value` → scene_metadata; a list value becomes one line per item. */
|
|
33
|
+
function metadataNode(meta) {
|
|
34
|
+
if (meta.items) {
|
|
35
|
+
return schema.nodes['scene_metadata'].create({ metaKey: meta.key, list: true }, textNodes(meta.items.join('\n')));
|
|
36
|
+
}
|
|
37
|
+
return schema.nodes['scene_metadata'].create({ metaKey: meta.key }, textNodes(meta.value));
|
|
38
|
+
}
|
|
31
39
|
function sceneContentToNodes(nodes) {
|
|
32
40
|
const result = [];
|
|
33
41
|
for (const node of nodes) {
|
|
@@ -43,7 +51,10 @@ function sceneContentToNodes(nodes) {
|
|
|
43
51
|
const cueText = [node.name, node.extension, node.isDual ? '^' : null]
|
|
44
52
|
.filter((part) => !!part)
|
|
45
53
|
.join(' ');
|
|
46
|
-
result.push(schema.nodes['character'].create({}, textNodes(cueText)));
|
|
54
|
+
result.push(schema.nodes['character'].create({ id: node.id, attrs: node.attrs }, textNodes(cueText)));
|
|
55
|
+
// Character-registry metadata (§8.2) follows the cue, before any dialogue.
|
|
56
|
+
for (const meta of node.metadata ?? [])
|
|
57
|
+
result.push(metadataNode(meta));
|
|
47
58
|
for (const child of node.children) {
|
|
48
59
|
if (child.type === 'dialogue') {
|
|
49
60
|
result.push(schema.nodes['dialogue'].create({}, spansToNodes(child.spans)));
|
|
@@ -88,20 +99,25 @@ function frontmatterToNode(doc) {
|
|
|
88
99
|
const fm = doc.frontmatter;
|
|
89
100
|
if (!fm)
|
|
90
101
|
return null;
|
|
102
|
+
const fields = frontmatterFields(fm.data).map(([key, value]) => schema.nodes['frontmatter_field'].create({ fmKey: key }, textNodes(value)));
|
|
103
|
+
if (fields.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
return schema.nodes['title_page'].create({ raw: fm.raw }, fields);
|
|
106
|
+
}
|
|
107
|
+
/** The (fmKey, value) pairs a title_page shows for parsed frontmatter data. */
|
|
108
|
+
export function frontmatterFields(data) {
|
|
91
109
|
const fields = [];
|
|
92
|
-
for (const [key, value] of Object.entries(
|
|
110
|
+
for (const [key, value] of Object.entries(data)) {
|
|
93
111
|
if (value !== null && typeof value === 'object') {
|
|
94
112
|
for (const [sub, subVal] of Object.entries(value)) {
|
|
95
|
-
fields.push(
|
|
113
|
+
fields.push([`${key}.${sub}`, String(subVal)]);
|
|
96
114
|
}
|
|
97
115
|
}
|
|
98
116
|
else {
|
|
99
|
-
fields.push(
|
|
117
|
+
fields.push([key, String(value ?? '')]);
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
|
-
|
|
103
|
-
return null;
|
|
104
|
-
return schema.nodes['title_page'].create({}, fields);
|
|
120
|
+
return fields;
|
|
105
121
|
}
|
|
106
122
|
export function sutraToProsemirror(doc) {
|
|
107
123
|
const topNodes = [];
|
|
@@ -111,38 +127,20 @@ export function sutraToProsemirror(doc) {
|
|
|
111
127
|
for (const node of doc.children) {
|
|
112
128
|
switch (node.type) {
|
|
113
129
|
case 'section':
|
|
114
|
-
topNodes.push(schema.nodes['section'].create({ level: node.level, id: node.id }, textNodes(node.text)));
|
|
130
|
+
topNodes.push(schema.nodes['section'].create({ level: node.level, id: node.id, attrs: node.attrs }, textNodes(node.text)));
|
|
115
131
|
break;
|
|
116
132
|
case 'scene-heading': {
|
|
117
133
|
const sceneNode = node;
|
|
118
|
-
topNodes.push(schema.nodes['scene_heading'].create({ id: sceneNode.id }, textNodes(sceneNode.text)));
|
|
119
|
-
for (const meta of sceneNode.metadata)
|
|
120
|
-
topNodes.push(
|
|
121
|
-
}
|
|
134
|
+
topNodes.push(schema.nodes['scene_heading'].create({ id: sceneNode.id, attrs: sceneNode.attrs }, textNodes(sceneNode.text)));
|
|
135
|
+
for (const meta of sceneNode.metadata)
|
|
136
|
+
topNodes.push(metadataNode(meta));
|
|
122
137
|
topNodes.push(...sceneContentToNodes(sceneNode.children));
|
|
123
138
|
break;
|
|
124
139
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
topNodes.push(schema.nodes['transition'].create({}, textNodes(node.text)));
|
|
130
|
-
break;
|
|
131
|
-
case 'centered':
|
|
132
|
-
topNodes.push(schema.nodes['centered'].create({}, textNodes(node.text)));
|
|
133
|
-
break;
|
|
134
|
-
case 'lyrics':
|
|
135
|
-
topNodes.push(schema.nodes['lyrics'].create({}, spansToNodes(node.spans)));
|
|
136
|
-
break;
|
|
137
|
-
case 'note':
|
|
138
|
-
topNodes.push(schema.nodes['note'].create({}, textNodes(node.text)));
|
|
139
|
-
break;
|
|
140
|
-
case 'comment':
|
|
141
|
-
topNodes.push(schema.nodes['comment'].create({}, textNodes(node.text)));
|
|
142
|
-
break;
|
|
143
|
-
case 'page-break':
|
|
144
|
-
topNodes.push(schema.nodes['page_break'].create());
|
|
145
|
-
break;
|
|
140
|
+
default:
|
|
141
|
+
// Everything else (including cues outside a scene, e.g. a cold open
|
|
142
|
+
// or the {#characters} registry) maps exactly as inside a scene.
|
|
143
|
+
topNodes.push(...sceneContentToNodes([node]));
|
|
146
144
|
}
|
|
147
145
|
}
|
|
148
146
|
// doc content is 'title_page? block+' — always need at least one body block
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Opaque JSON Schema object describing a structured response. */
|
|
2
|
+
export type JsonSchema = Record<string, unknown>;
|
|
3
|
+
export interface AIRequest {
|
|
4
|
+
/** System prompt. The editor's prompts (prompts.ts) describe Sutra, not a provider. */
|
|
5
|
+
system?: string;
|
|
6
|
+
prompt: string;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
/** One in-flight AI call, for the status bar ("Calling <label>..."). */
|
|
10
|
+
export interface AIActivity {
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* LLM access, injected by the embedder (OSS spec §11.5). The OSS app supplies
|
|
15
|
+
* a bring-your-own-key provider; Sutrata Cloud supplies its own agents.
|
|
16
|
+
* Optional: without one, the editor hides every AI feature.
|
|
17
|
+
*/
|
|
18
|
+
export interface AIProvider {
|
|
19
|
+
id: string;
|
|
20
|
+
displayName: string;
|
|
21
|
+
/** Shown before any document content is sent (e.g. AI-assisted import). */
|
|
22
|
+
dataPolicyText: string;
|
|
23
|
+
/** Whether a call can succeed right now (e.g. an API key is set). */
|
|
24
|
+
isConfigured(): boolean | Promise<boolean>;
|
|
25
|
+
/** Free-text completion. */
|
|
26
|
+
complete(req: AIRequest): Promise<string>;
|
|
27
|
+
/** Completion that returns a value matching `schema`; the provider validates/parses it. */
|
|
28
|
+
completeStructured<T = unknown>(req: AIRequest & {
|
|
29
|
+
schema: JsonSchema;
|
|
30
|
+
}): Promise<T>;
|
|
31
|
+
/** Optional: in-flight calls, for the status bar. Calls `cb` immediately with the current list. */
|
|
32
|
+
subscribeActivity?(cb: (calls: AIActivity[]) => void): () => void;
|
|
33
|
+
/** Optional: open the embedder's setup UI, used when isConfigured() is false. */
|
|
34
|
+
openSetup?(): void;
|
|
35
|
+
}
|
|
36
|
+
export declare const AIProviderProvider: import("react").Provider<AIProvider | null>;
|
|
37
|
+
/**
|
|
38
|
+
* The AI provider, or null when AI is unavailable in this session: no
|
|
39
|
+
* provider was injected, the session cannot edit, or `featureFlags.ai` is off.
|
|
40
|
+
* Components hide their AI UI when this is null.
|
|
41
|
+
*/
|
|
42
|
+
export declare function useAI(): AIProvider | null;
|
|
43
|
+
/** `Calling <label>...` text for the status bar; '' when idle. */
|
|
44
|
+
export declare function formatActivity(calls: AIActivity[]): string;
|