regen.mde 0.2.2 → 0.7.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/README.md +409 -295
- package/bin/build-corpus-editor.js +5 -3
- package/bin/postinstall.js +259 -187
- package/bin/regen-mdeditor-install.js +1 -1
- package/bin/regen-mdeditor-uninstall.js +1 -1
- package/desktop/BuildCorpusEditor/BuildCorpusBridge.cs +493 -270
- package/desktop/BuildCorpusEditor/EditorForm.cs +853 -540
- package/desktop/BuildCorpusEditor/Program.cs +85 -81
- package/dist/release/regen-mde-0.3.0-win-x64-setup.exe +0 -0
- package/dist/release/{regen.mde-0.2.2-win-x64.zip → regen-mde-0.3.0-win-x64.zip} +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64-setup.exe +0 -0
- package/dist/release/regen-mde-0.7.0-win-x64.zip +0 -0
- package/dist/windows-editor/BuildCorpusEditor.dll +0 -0
- package/dist/windows-editor/BuildCorpusEditor.exe +0 -0
- package/dist/windows-editor/BuildCorpusEditor.pdb +0 -0
- package/dist/windows-editor/wwwroot/assets/index-C_VxJk4k.js +375 -0
- package/dist/windows-editor/wwwroot/assets/index-Wt9zSjIw.css +1 -0
- package/dist/windows-editor/wwwroot/index.html +3 -3
- package/editor-web/index.html +1 -1
- package/editor-web/src/main.jsx +1044 -399
- package/editor-web/src/styles.css +846 -602
- package/installer/install-regen-mde.ps1 +49 -10
- package/installer/regen-mde.nsi +16 -16
- package/package.json +90 -86
- package/pyproject.toml +35 -33
- package/requirements.txt +6 -4
- package/scripts/package-windows-editor.ps1 +8 -8
- package/scripts/release-dual.mjs +105 -0
- package/scripts/run-editor-implementation-plane.ps1 +29 -6
- package/src/build_corpus/docx_exporter.py +1055 -798
- package/src/build_corpus/equations.py +80 -0
- package/src/build_corpus/exporter.py +1488 -1195
- package/src/build_corpus/frontmatter.py +302 -0
- package/src/build_corpus/ppt_exporter.py +543 -532
- package/dist/release/regen.mde-0.2.2-win-x64-setup.exe +0 -0
- package/dist/windows-editor/wwwroot/assets/index-DjJ6xmhy.js +0 -326
- package/dist/windows-editor/wwwroot/assets/index-_dwMNNsm.css +0 -1
package/editor-web/src/main.jsx
CHANGED
|
@@ -1,399 +1,1044 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { createRoot } from "react-dom/client";
|
|
3
|
-
import { EditorContent, useEditor } from "@tiptap/react";
|
|
4
|
-
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
|
|
5
|
-
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
-
import { Link } from "@tiptap/extension-link";
|
|
7
|
-
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
8
|
-
import { Image } from "@tiptap/extension-image";
|
|
9
|
-
import { Table } from "@tiptap/extension-table";
|
|
10
|
-
import { TableRow } from "@tiptap/extension-table-row";
|
|
11
|
-
import { TableHeader } from "@tiptap/extension-table-header";
|
|
12
|
-
import { TableCell } from "@tiptap/extension-table-cell";
|
|
13
|
-
import { TaskList } from "@tiptap/extension-task-list";
|
|
14
|
-
import { TaskItem } from "@tiptap/extension-task-item";
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import "
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
4
|
+
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import { Link } from "@tiptap/extension-link";
|
|
7
|
+
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
8
|
+
import { Image } from "@tiptap/extension-image";
|
|
9
|
+
import { Table } from "@tiptap/extension-table";
|
|
10
|
+
import { TableRow } from "@tiptap/extension-table-row";
|
|
11
|
+
import { TableHeader } from "@tiptap/extension-table-header";
|
|
12
|
+
import { TableCell } from "@tiptap/extension-table-cell";
|
|
13
|
+
import { TaskList } from "@tiptap/extension-task-list";
|
|
14
|
+
import { TaskItem } from "@tiptap/extension-task-item";
|
|
15
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
16
|
+
import { markdown as markdownLanguage } from "@codemirror/lang-markdown";
|
|
17
|
+
import { marked } from "marked";
|
|
18
|
+
import TurndownService from "turndown";
|
|
19
|
+
import "./styles.css";
|
|
20
|
+
|
|
21
|
+
const APP_VERSION = "0.6.0";
|
|
22
|
+
|
|
23
|
+
const turndown = new TurndownService({
|
|
24
|
+
headingStyle: "atx",
|
|
25
|
+
bulletListMarker: "-",
|
|
26
|
+
codeBlockStyle: "fenced",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function normalizeCellMarkdown(markdown) {
|
|
30
|
+
return String(markdown || "")
|
|
31
|
+
.replace(/\n+/g, " ")
|
|
32
|
+
.replace(/\|/g, "\\|")
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
turndown.addRule("gfmStrike", {
|
|
37
|
+
filter: ["del", "s", "strike"],
|
|
38
|
+
replacement(content) {
|
|
39
|
+
return `~~${content}~~`;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
turndown.addRule("gfmTaskItem", {
|
|
44
|
+
filter(node) {
|
|
45
|
+
return node.nodeName === "LI" && (node.getAttribute("data-type") === "taskItem" || node.hasAttribute("data-checked"));
|
|
46
|
+
},
|
|
47
|
+
replacement(content, node) {
|
|
48
|
+
const checked = node.getAttribute("data-checked") === "true" ? "x" : " ";
|
|
49
|
+
return `- [${checked}] ${content.replace(/\n+/g, " ").trim()}\n`;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
turndown.addRule("gfmTable", {
|
|
54
|
+
filter: "table",
|
|
55
|
+
replacement(_content, node) {
|
|
56
|
+
const rows = Array.from(node.querySelectorAll("tr"));
|
|
57
|
+
if (!rows.length) return "";
|
|
58
|
+
const matrix = rows
|
|
59
|
+
.map((row) => Array.from(row.children)
|
|
60
|
+
.filter((cell) => cell.matches("th,td"))
|
|
61
|
+
.map((cell) => normalizeCellMarkdown(turndown.turndown(cell.innerHTML))))
|
|
62
|
+
.filter((row) => row.length);
|
|
63
|
+
if (!matrix.length) return "";
|
|
64
|
+
const columnCount = Math.max(...matrix.map((row) => row.length));
|
|
65
|
+
const normalizedRows = matrix.map((row) => Array.from({ length: columnCount }, (_, index) => row[index] || " "));
|
|
66
|
+
const header = normalizedRows[0];
|
|
67
|
+
const body = normalizedRows.slice(1);
|
|
68
|
+
const headerLine = `| ${header.join(" | ")} |`;
|
|
69
|
+
const separatorLine = `| ${Array.from({ length: columnCount }, () => "---").join(" | ")} |`;
|
|
70
|
+
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`);
|
|
71
|
+
return `\n\n${[headerLine, separatorLine, ...bodyLines].join("\n")}\n\n`;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const RICH_EDITOR_LIMIT = 50000;
|
|
76
|
+
|
|
77
|
+
const bridge = {
|
|
78
|
+
nextId: 1,
|
|
79
|
+
pending: new Map(),
|
|
80
|
+
call(method, params = {}) {
|
|
81
|
+
const id = this.nextId++;
|
|
82
|
+
const payload = { id, method, params };
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
this.pending.set(id, { resolve, reject });
|
|
85
|
+
if (window.chrome?.webview) {
|
|
86
|
+
window.chrome.webview.postMessage(payload);
|
|
87
|
+
} else {
|
|
88
|
+
reject(new Error("Native bridge is not available."));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (window.chrome?.webview) {
|
|
95
|
+
window.chrome.webview.addEventListener("message", (event) => {
|
|
96
|
+
const message = event.data;
|
|
97
|
+
const pending = bridge.pending.get(message.id);
|
|
98
|
+
if (!pending) return;
|
|
99
|
+
bridge.pending.delete(message.id);
|
|
100
|
+
if (message.ok) pending.resolve(message.result);
|
|
101
|
+
else pending.reject(new Error(message.error || "Native bridge failed."));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function markdownToHtml(markdown) {
|
|
106
|
+
return marked.parse(markdown || "", { gfm: true, breaks: false });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function htmlToMarkdown(html) {
|
|
110
|
+
return turndown.turndown(html || "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findEnclosingSyntax(markdown, start, end, prefix, suffix = prefix) {
|
|
114
|
+
const before = markdown.lastIndexOf(prefix, Math.max(0, start - 1));
|
|
115
|
+
if (before < 0) return null;
|
|
116
|
+
const close = markdown.indexOf(suffix, Math.max(end, before + prefix.length));
|
|
117
|
+
if (close < 0) return null;
|
|
118
|
+
if (start < before + prefix.length || end > close) return null;
|
|
119
|
+
if (prefix === "*" && (markdown[before - 1] === "*" || markdown[before + 1] === "*" || markdown[close - 1] === "*" || markdown[close + 1] === "*")) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
if (prefix === "`" && markdown.slice(before + prefix.length, close).includes("\n")) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return { open: before, close };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ReadOnlyMarkdownPreview({ markdown, formatLabel, pageAreaRef, onScroll }) {
|
|
129
|
+
const deferredMarkdown = React.useDeferredValue(markdown);
|
|
130
|
+
const html = React.useMemo(() => markdownToHtml(deferredMarkdown), [deferredMarkdown]);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<section className="page-area" ref={pageAreaRef} onScroll={onScroll}>
|
|
134
|
+
<article className="page readonly-preview">
|
|
135
|
+
<p className="page-label">{formatLabel === "NEW" ? "New Markdown draft" : `Converted from ${formatLabel}`}</p>
|
|
136
|
+
<div className="rendered-markdown" dangerouslySetInnerHTML={{ __html: html }} />
|
|
137
|
+
</article>
|
|
138
|
+
</section>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ToolbarButton({ label, title, active, disabled, onClick, className = "" }) {
|
|
143
|
+
return (
|
|
144
|
+
<button type="button" title={title || label} className={`${active ? "active" : ""} ${className}`.trim()} disabled={disabled} onClick={onClick}>
|
|
145
|
+
{label}
|
|
146
|
+
</button>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function App() {
|
|
151
|
+
const [markdown, setMarkdown] = React.useState("# regen-mde\n\nOpen a Markdown or Word file to begin.");
|
|
152
|
+
const [markdownPath, setMarkdownPath] = React.useState("");
|
|
153
|
+
const [workingPath, setWorkingPath] = React.useState("");
|
|
154
|
+
const [sourcePath, setSourcePath] = React.useState("");
|
|
155
|
+
const [exportPath, setExportPath] = React.useState("");
|
|
156
|
+
const [originalFormat, setOriginalFormat] = React.useState("");
|
|
157
|
+
const [status, setStatus] = React.useState("Ready");
|
|
158
|
+
const [mode, setMode] = React.useState("markdown");
|
|
159
|
+
const [theme, setTheme] = React.useState(() => {
|
|
160
|
+
const saved = window.localStorage?.getItem("build-corpus-editor-theme");
|
|
161
|
+
if (saved === "dark" || saved === "light") return saved;
|
|
162
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
163
|
+
});
|
|
164
|
+
const [dirty, setDirty] = React.useState(false);
|
|
165
|
+
const [largeFile, setLargeFile] = React.useState(false);
|
|
166
|
+
const [moveSources, setMoveSources] = React.useState(false);
|
|
167
|
+
const sourceViewRef = React.useRef(null);
|
|
168
|
+
const pageAreaRef = React.useRef(null);
|
|
169
|
+
const scrollSyncRef = React.useRef(false);
|
|
170
|
+
const [sourceScroller, setSourceScroller] = React.useState(null);
|
|
171
|
+
const [sourceSelectionRange, setSourceSelectionRange] = React.useState({ start: 0, end: 0, ready: false });
|
|
172
|
+
|
|
173
|
+
const editor = useEditor({
|
|
174
|
+
extensions: [
|
|
175
|
+
StarterKit,
|
|
176
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
177
|
+
Placeholder.configure({ placeholder: "Start writing..." }),
|
|
178
|
+
Image.configure({ inline: false, allowBase64: true }),
|
|
179
|
+
Table.configure({ resizable: true }),
|
|
180
|
+
TableRow,
|
|
181
|
+
TableHeader,
|
|
182
|
+
TableCell,
|
|
183
|
+
TaskList,
|
|
184
|
+
TaskItem.configure({ nested: true }),
|
|
185
|
+
],
|
|
186
|
+
content: markdownToHtml(markdown),
|
|
187
|
+
immediatelyRender: false,
|
|
188
|
+
onUpdate({ editor }) {
|
|
189
|
+
const next = htmlToMarkdown(editor.getHTML());
|
|
190
|
+
setMarkdown(next);
|
|
191
|
+
setDirty(true);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const loadDocument = React.useCallback(async (path) => {
|
|
196
|
+
setStatus("Opening...");
|
|
197
|
+
const doc = await bridge.call("open", { path });
|
|
198
|
+
const format = doc.originalFormat || "";
|
|
199
|
+
const isMarkdown = format === "markdown";
|
|
200
|
+
setMarkdownPath(doc.workingPath || (isMarkdown ? doc.sourcePath || "" : ""));
|
|
201
|
+
setWorkingPath(doc.workingPath || "");
|
|
202
|
+
setSourcePath(doc.sourcePath || "");
|
|
203
|
+
setExportPath("");
|
|
204
|
+
setOriginalFormat(format);
|
|
205
|
+
setMarkdown(doc.content || "");
|
|
206
|
+
const isLarge = (doc.content || "").length > RICH_EDITOR_LIMIT;
|
|
207
|
+
setLargeFile(isLarge);
|
|
208
|
+
if (!isLarge) editor?.commands.setContent(markdownToHtml(doc.content || ""));
|
|
209
|
+
setDirty(false);
|
|
210
|
+
setMode(isLarge ? "raw" : "markdown");
|
|
211
|
+
setStatus(`Opened ${doc.sourcePath}`);
|
|
212
|
+
}, [editor]);
|
|
213
|
+
|
|
214
|
+
React.useEffect(() => {
|
|
215
|
+
bridge.call("startup").then((startup) => {
|
|
216
|
+
if (startup.initialPath) loadDocument(startup.initialPath);
|
|
217
|
+
}).catch((error) => setStatus(error.message));
|
|
218
|
+
}, [loadDocument]);
|
|
219
|
+
|
|
220
|
+
React.useEffect(() => {
|
|
221
|
+
if (editor && !dirty && markdown.length <= RICH_EDITOR_LIMIT) editor.commands.setContent(markdownToHtml(markdown));
|
|
222
|
+
}, [editor, markdown, dirty]);
|
|
223
|
+
|
|
224
|
+
React.useEffect(() => {
|
|
225
|
+
window.localStorage?.setItem("build-corpus-editor-theme", theme);
|
|
226
|
+
}, [theme]);
|
|
227
|
+
|
|
228
|
+
async function chooseOpen() {
|
|
229
|
+
try {
|
|
230
|
+
const result = await bridge.call("chooseOpen");
|
|
231
|
+
if (result?.path) await loadDocument(result.path);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
setStatus(error.message);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function save() {
|
|
238
|
+
try {
|
|
239
|
+
setStatus("Saving...");
|
|
240
|
+
const result = await bridge.call("save", { path: markdownPath, content: markdown });
|
|
241
|
+
setMarkdownPath(result.output);
|
|
242
|
+
setWorkingPath(result.output);
|
|
243
|
+
setDirty(false);
|
|
244
|
+
setStatus(`Saved Markdown ${result.output}`);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
setStatus(error.message);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function saveAs(format) {
|
|
251
|
+
try {
|
|
252
|
+
setStatus("Saving as...");
|
|
253
|
+
const smokeOut = window.__REGEN_MDEDITOR_SMOKE_OUT;
|
|
254
|
+
const method = smokeOut ? "saveAsDirect" : "saveAs";
|
|
255
|
+
const params = { suggestedPath: markdownPath || sourcePath || workingPath, format, content: markdown };
|
|
256
|
+
if (smokeOut) {
|
|
257
|
+
const extension = format === "word" ? ".docx" : ".md";
|
|
258
|
+
params.targetPath = `${smokeOut}\\ui-save-as-${format}${extension}`;
|
|
259
|
+
}
|
|
260
|
+
const result = await bridge.call(method, params);
|
|
261
|
+
if (result?.output) {
|
|
262
|
+
if (format === "markdown") {
|
|
263
|
+
setMarkdownPath(result.output);
|
|
264
|
+
setWorkingPath(result.output);
|
|
265
|
+
setDirty(false);
|
|
266
|
+
setStatus(`Saved Markdown ${result.output}`);
|
|
267
|
+
} else {
|
|
268
|
+
setExportPath(result.output);
|
|
269
|
+
setStatus(`Exported Word ${result.output}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
setStatus(error.message);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function setLink() {
|
|
278
|
+
const previous = editor?.getAttributes("link").href || "";
|
|
279
|
+
const href = window.prompt("URL", previous);
|
|
280
|
+
if (href === null) return;
|
|
281
|
+
if (href.trim() === "") {
|
|
282
|
+
editor?.chain().focus().unsetLink().run();
|
|
283
|
+
} else {
|
|
284
|
+
editor?.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function insertImageFile() {
|
|
289
|
+
try {
|
|
290
|
+
const result = await bridge.call("chooseImage");
|
|
291
|
+
if (result?.src) {
|
|
292
|
+
editor?.chain().focus().setImage({ src: result.src, alt: result.alt || "" }).run();
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
setStatus(error.message);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function insertImageUrl() {
|
|
300
|
+
const src = window.prompt("Image URL");
|
|
301
|
+
if (src) editor?.chain().focus().setImage({ src: src.trim() }).run();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function convertFolder() {
|
|
305
|
+
try {
|
|
306
|
+
const selected = await bridge.call("chooseFolder");
|
|
307
|
+
if (!selected?.path) return;
|
|
308
|
+
setStatus("Converting folder...");
|
|
309
|
+
const result = await bridge.call("convertBatch", { path: selected.path, moveSources });
|
|
310
|
+
setStatus(`Converted ${result.outputs?.length || 0} files from ${result.input}`);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
setStatus(error.message);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function inlineImages() {
|
|
317
|
+
try {
|
|
318
|
+
setStatus("Inlining images...");
|
|
319
|
+
const result = await bridge.call("inlineImages", { content: markdown, basePath: markdownPath || workingPath || sourcePath });
|
|
320
|
+
setMarkdown(result.content || markdown);
|
|
321
|
+
setDirty(true);
|
|
322
|
+
setLargeFile((result.content || markdown).length > RICH_EDITOR_LIMIT);
|
|
323
|
+
setMode("raw");
|
|
324
|
+
setStatus(`Inlined ${result.converted || 0} images${result.skipped?.length ? `; skipped ${result.skipped.length}` : ""}`);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
setStatus(error.message);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function syncSource(next) {
|
|
331
|
+
setMarkdown(next);
|
|
332
|
+
setLargeFile(next.length > RICH_EDITOR_LIMIT);
|
|
333
|
+
setDirty(true);
|
|
334
|
+
if (editor && next.length <= RICH_EDITOR_LIMIT) editor.commands.setContent(markdownToHtml(next));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function syncScrollRatio(from, to) {
|
|
338
|
+
if (!from || !to) return;
|
|
339
|
+
const fromRange = Math.max(1, from.scrollHeight - from.clientHeight);
|
|
340
|
+
const toRange = Math.max(0, to.scrollHeight - to.clientHeight);
|
|
341
|
+
const ratio = from.scrollTop / fromRange;
|
|
342
|
+
to.scrollTop = ratio * toRange;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const handlePreviewScroll = React.useCallback((event) => {
|
|
346
|
+
if (mode !== "split" || scrollSyncRef.current) return;
|
|
347
|
+
scrollSyncRef.current = true;
|
|
348
|
+
syncScrollRatio(event.currentTarget, sourceViewRef.current?.scrollDOM);
|
|
349
|
+
window.requestAnimationFrame(() => {
|
|
350
|
+
scrollSyncRef.current = false;
|
|
351
|
+
});
|
|
352
|
+
}, [mode]);
|
|
353
|
+
|
|
354
|
+
React.useEffect(() => {
|
|
355
|
+
if (!sourceScroller) return undefined;
|
|
356
|
+
function handleSourceScroll(event) {
|
|
357
|
+
if (mode !== "split" || scrollSyncRef.current) return;
|
|
358
|
+
scrollSyncRef.current = true;
|
|
359
|
+
syncScrollRatio(event.currentTarget, pageAreaRef.current);
|
|
360
|
+
window.requestAnimationFrame(() => {
|
|
361
|
+
scrollSyncRef.current = false;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
sourceScroller.addEventListener("scroll", handleSourceScroll, { passive: true });
|
|
365
|
+
return () => sourceScroller.removeEventListener("scroll", handleSourceScroll);
|
|
366
|
+
}, [mode, sourceScroller]);
|
|
367
|
+
|
|
368
|
+
function updateSourceSelection(next, selectionStart, selectionEnd) {
|
|
369
|
+
syncSource(next);
|
|
370
|
+
setSourceSelectionRange({ start: selectionStart, end: selectionEnd, ready: true });
|
|
371
|
+
window.requestAnimationFrame(() => {
|
|
372
|
+
const view = sourceViewRef.current;
|
|
373
|
+
if (!view) return;
|
|
374
|
+
view.focus();
|
|
375
|
+
view.dispatch({ selection: { anchor: selectionStart, head: selectionEnd } });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sourceSelection() {
|
|
380
|
+
const view = sourceViewRef.current;
|
|
381
|
+
if (view) {
|
|
382
|
+
const range = view.state.selection.main;
|
|
383
|
+
if (sourceSelectionRange.ready && range.from === 0 && range.to === 0 && (sourceSelectionRange.start !== 0 || sourceSelectionRange.end !== 0)) {
|
|
384
|
+
return sourceSelectionRange;
|
|
385
|
+
}
|
|
386
|
+
return { start: range.from, end: range.to, ready: true };
|
|
387
|
+
}
|
|
388
|
+
return sourceSelectionRange.ready ? sourceSelectionRange : { start: markdown.length, end: markdown.length };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function wrapSelection(prefix, suffix = prefix, placeholder = "text") {
|
|
392
|
+
const { start, end } = sourceSelection();
|
|
393
|
+
const selected = markdown.slice(start, end);
|
|
394
|
+
if (selected && selected.startsWith(prefix) && selected.endsWith(suffix)) {
|
|
395
|
+
const unwrapped = selected.slice(prefix.length, selected.length - suffix.length);
|
|
396
|
+
const next = `${markdown.slice(0, start)}${unwrapped}${markdown.slice(end)}`;
|
|
397
|
+
updateSourceSelection(next, start, start + unwrapped.length);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (start >= prefix.length && markdown.slice(start - prefix.length, start) === prefix && markdown.slice(end, end + suffix.length) === suffix) {
|
|
401
|
+
const next = `${markdown.slice(0, start - prefix.length)}${selected}${markdown.slice(end + suffix.length)}`;
|
|
402
|
+
updateSourceSelection(next, start - prefix.length, end - prefix.length);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const enclosing = findEnclosingSyntax(markdown, start, end, prefix, suffix);
|
|
406
|
+
if (enclosing) {
|
|
407
|
+
const next = `${markdown.slice(0, enclosing.open)}${markdown.slice(enclosing.open + prefix.length, enclosing.close)}${markdown.slice(enclosing.close + suffix.length)}`;
|
|
408
|
+
const shift = start > enclosing.open ? prefix.length : 0;
|
|
409
|
+
updateSourceSelection(next, Math.max(enclosing.open, start - shift), Math.max(enclosing.open, end - shift));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const inserted = selected || placeholder;
|
|
413
|
+
const next = `${markdown.slice(0, start)}${prefix}${inserted}${suffix}${markdown.slice(end)}`;
|
|
414
|
+
updateSourceSelection(next, start + prefix.length, start + prefix.length + inserted.length);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function toggleLinePrefix(marker, familyRegex = null) {
|
|
418
|
+
const { start, end } = sourceSelection();
|
|
419
|
+
const lineStart = markdown.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
|
420
|
+
const lineEndIndex = markdown.indexOf("\n", start);
|
|
421
|
+
const lineEnd = lineEndIndex === -1 ? markdown.length : lineEndIndex;
|
|
422
|
+
const line = markdown.slice(lineStart, lineEnd);
|
|
423
|
+
const activeMatch = familyRegex ? line.match(familyRegex) : (line.startsWith(marker) ? [marker] : null);
|
|
424
|
+
if (activeMatch) {
|
|
425
|
+
const removeLength = activeMatch[0].length;
|
|
426
|
+
const next = `${markdown.slice(0, lineStart)}${line.slice(removeLength)}${markdown.slice(lineEnd)}`;
|
|
427
|
+
updateSourceSelection(next, Math.max(lineStart, start - removeLength), Math.max(lineStart, end - removeLength));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const next = `${markdown.slice(0, lineStart)}${marker}${markdown.slice(lineStart)}`;
|
|
431
|
+
updateSourceSelection(next, start + marker.length, end + marker.length);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function markdownSyntaxActive(prefix, suffix = prefix) {
|
|
435
|
+
const { start, end } = sourceSelection();
|
|
436
|
+
return Boolean(findEnclosingSyntax(markdown, start, end, prefix, suffix));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function activeMarkdownSyntax(prefixes) {
|
|
440
|
+
const { start, end } = sourceSelection();
|
|
441
|
+
return prefixes.find((marker) => findEnclosingSyntax(markdown, start, end, marker, marker)) || "";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function toggleItalic() {
|
|
445
|
+
const activeMarker = activeMarkdownSyntax(["*", "_"]);
|
|
446
|
+
wrapSelection(activeMarker || "*", activeMarker || "*", "italic");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function linePrefixActive(pattern) {
|
|
450
|
+
const { start } = sourceSelection();
|
|
451
|
+
const lineStart = markdown.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
|
452
|
+
const lineEndIndex = markdown.indexOf("\n", start);
|
|
453
|
+
const lineEnd = lineEndIndex === -1 ? markdown.length : lineEndIndex;
|
|
454
|
+
return pattern.test(markdown.slice(lineStart, lineEnd));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function insertSnippet(snippet, cursorOffset = snippet.length) {
|
|
458
|
+
const { start, end } = sourceSelection();
|
|
459
|
+
const next = `${markdown.slice(0, start)}${snippet}${markdown.slice(end)}`;
|
|
460
|
+
updateSourceSelection(next, start + cursorOffset, start + cursorOffset);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
React.useEffect(() => {
|
|
464
|
+
if (!window.__REGEN_MDEDITOR_SMOKE_OUT) return undefined;
|
|
465
|
+
window.__REGEN_MDEDITOR_SMOKE_API = {
|
|
466
|
+
appendMarkdown(text) {
|
|
467
|
+
const next = `${markdown}${text}`;
|
|
468
|
+
syncSource(next);
|
|
469
|
+
return next;
|
|
470
|
+
},
|
|
471
|
+
setMarkdown(text) {
|
|
472
|
+
const next = String(text || "");
|
|
473
|
+
syncSource(next);
|
|
474
|
+
return next;
|
|
475
|
+
},
|
|
476
|
+
setSelection(start, end = start) {
|
|
477
|
+
const view = sourceViewRef.current;
|
|
478
|
+
if (!view) throw new Error("Markdown editor view is not ready.");
|
|
479
|
+
const anchor = Math.max(0, Math.min(Number(start) || 0, view.state.doc.length));
|
|
480
|
+
const head = Math.max(0, Math.min(Number(end) || anchor, view.state.doc.length));
|
|
481
|
+
view.focus();
|
|
482
|
+
view.dispatch({ selection: { anchor, head } });
|
|
483
|
+
setSourceSelectionRange({ start: anchor, end: head, ready: true });
|
|
484
|
+
return `${anchor}:${head}`;
|
|
485
|
+
},
|
|
486
|
+
appendRichText(text) {
|
|
487
|
+
if (!editor) throw new Error("Rich editor is not ready.");
|
|
488
|
+
const paragraphs = String(text)
|
|
489
|
+
.split(/\n{2,}/)
|
|
490
|
+
.map((part) => part.trim())
|
|
491
|
+
.filter(Boolean)
|
|
492
|
+
.map((part) => `<p>${part.replace(/[&<>"']/g, (char) => ({
|
|
493
|
+
"&": "&",
|
|
494
|
+
"<": "<",
|
|
495
|
+
">": ">",
|
|
496
|
+
'"': """,
|
|
497
|
+
"'": "'",
|
|
498
|
+
}[char]))}</p>`)
|
|
499
|
+
.join("");
|
|
500
|
+
editor.chain().focus().insertContent(paragraphs || "<p></p>").run();
|
|
501
|
+
return htmlToMarkdown(editor.getHTML());
|
|
502
|
+
},
|
|
503
|
+
getMarkdown() {
|
|
504
|
+
return markdown;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
return () => {
|
|
508
|
+
delete window.__REGEN_MDEDITOR_SMOKE_API;
|
|
509
|
+
};
|
|
510
|
+
}, [markdown, editor]);
|
|
511
|
+
|
|
512
|
+
const canUseRich = !largeFile;
|
|
513
|
+
const visiblePath = sourcePath || markdownPath || workingPath || "No file open";
|
|
514
|
+
const formatLabel = originalFormat ? originalFormat.toUpperCase() : "NEW";
|
|
515
|
+
const wordCount = markdown.trim() ? markdown.trim().split(/\s+/).length : 0;
|
|
516
|
+
const tableCount = (markdown.match(/\n\|/g) || []).length > 1 ? 1 : 0;
|
|
517
|
+
const imageCount = (markdown.match(/!\[[^\]]*]\(/g) || []).length;
|
|
518
|
+
const linkCount = (markdown.match(/https?:\/\//g) || []).length;
|
|
519
|
+
const confidence = Math.max(18, Math.min(94, 92 - imageCount * 8 - tableCount * 5));
|
|
520
|
+
const sourceBadge = formatLabel === "MARKDOWN" ? "MD" : formatLabel === "NEW" ? "NEW" : formatLabel;
|
|
521
|
+
const sourceText = sourcePath ? `${formatLabel} source loaded` : "No source loaded";
|
|
522
|
+
const draftText = markdownPath ? "Markdown target selected" : "Unsaved Markdown draft";
|
|
523
|
+
const exportText = exportPath ? "Word export written" : "Word export ready";
|
|
524
|
+
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
525
|
+
const markdownToolbarDisabled = false;
|
|
526
|
+
const codeMirrorExtensions = React.useMemo(() => [markdownLanguage()], []);
|
|
527
|
+
const codeMirrorTheme = "light";
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<main className="app-shell" data-theme={theme}>
|
|
531
|
+
<aside className="brand-rail" aria-label="Primary tools">
|
|
532
|
+
<div className="mark">BC</div>
|
|
533
|
+
<div className="rail-item active">Ed</div>
|
|
534
|
+
<div className="rail-item">Cv</div>
|
|
535
|
+
<div className="rail-item">Tb</div>
|
|
536
|
+
<div className="rail-item">Im</div>
|
|
537
|
+
<div className="rail-item">Qa</div>
|
|
538
|
+
<div />
|
|
539
|
+
<div className="rail-item">?</div>
|
|
540
|
+
</aside>
|
|
541
|
+
|
|
542
|
+
<header className="topbar">
|
|
543
|
+
<div className="file-title">
|
|
544
|
+
<strong>regen-mde</strong>
|
|
545
|
+
<span title={visiblePath}>{visiblePath}</span>
|
|
546
|
+
</div>
|
|
547
|
+
<div className="actions">
|
|
548
|
+
<button className="ghost" onClick={chooseOpen}>Open</button>
|
|
549
|
+
<button onClick={save} disabled={!markdownPath}>Save MD</button>
|
|
550
|
+
<button onClick={() => saveAs("markdown")}>Save MD As...</button>
|
|
551
|
+
<button onClick={() => saveAs("word")}>Export Word...</button>
|
|
552
|
+
<button className="ghost" onClick={inlineImages} disabled={!markdown}>Inline Images</button>
|
|
553
|
+
<button className="ghost" onClick={convertFolder}>Convert Folder</button>
|
|
554
|
+
<button className="ghost" onClick={() => setTheme(nextTheme)}>{theme === "dark" ? "Light" : "Dark"}</button>
|
|
555
|
+
<button className="primary" onClick={() => setStatus(`Checked ${wordCount} words, ${tableCount} tables, ${imageCount} images, ${linkCount} links`)}>Run Check</button>
|
|
556
|
+
</div>
|
|
557
|
+
</header>
|
|
558
|
+
|
|
559
|
+
<section className="side-panel left-panel">
|
|
560
|
+
<h2 className="panel-title">Document Flow</h2>
|
|
561
|
+
<div className="source-card">
|
|
562
|
+
<strong>{sourceText}</strong>
|
|
563
|
+
<p>{dirty ? "The Markdown draft has unsaved changes." : "The Markdown draft is in sync with its save target."}</p>
|
|
564
|
+
<label className="batch-option"><input type="checkbox" checked={moveSources} onChange={(event) => setMoveSources(event.target.checked)} /> Move processed files to sources</label>
|
|
565
|
+
</div>
|
|
566
|
+
<div className="format-flow">
|
|
567
|
+
<div className="flow-step">
|
|
568
|
+
<div className="badge">{sourceBadge}</div>
|
|
569
|
+
<div><b>Original</b><small>{sourcePath || "Choose a file to begin"}</small></div>
|
|
570
|
+
<small>{sourcePath ? "loaded" : "empty"}</small>
|
|
571
|
+
</div>
|
|
572
|
+
<div className="flow-step">
|
|
573
|
+
<div className="badge">MD</div>
|
|
574
|
+
<div><b>Working draft</b><small>{draftText}</small></div>
|
|
575
|
+
<small>{dirty ? "dirty" : "saved"}</small>
|
|
576
|
+
</div>
|
|
577
|
+
<div className="flow-step">
|
|
578
|
+
<div className="badge">DOCX</div>
|
|
579
|
+
<div><b>Word export</b><small>{exportText}</small></div>
|
|
580
|
+
<small>{exportPath ? "done" : "ready"}</small>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<h2 className="panel-title">Insert</h2>
|
|
585
|
+
<div className="insert-grid">
|
|
586
|
+
<button className="insert-tile" disabled={!canUseRich} onClick={() => editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}><b>Table</b>Rows, columns, headers</button>
|
|
587
|
+
<button className="insert-tile" disabled={!canUseRich} onClick={insertImageFile}><b>Image</b>File with alt text</button>
|
|
588
|
+
<button className="insert-tile" disabled={!canUseRich} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><b>Callout</b>Note or quotation</button>
|
|
589
|
+
<button className="insert-tile" disabled={!canUseRich} onClick={() => editor?.chain().focus().toggleTaskList().run()}><b>Task</b>Checklist block</button>
|
|
590
|
+
</div>
|
|
591
|
+
</section>
|
|
592
|
+
|
|
593
|
+
<section className={`workspace ${mode} ${largeFile ? "large" : ""}`}>
|
|
594
|
+
<div className="mode-strip">
|
|
595
|
+
<div className="segmented" aria-label="Editor mode">
|
|
596
|
+
<button className={mode === "markdown" ? "active" : ""} onClick={() => setMode("markdown")}>Markdown</button>
|
|
597
|
+
<button className={mode === "raw" ? "active" : ""} onClick={() => setMode("raw")}>Raw</button>
|
|
598
|
+
<button className={mode === "split" ? "active" : ""} onClick={() => setMode("split")}>Split Screen</button>
|
|
599
|
+
</div>
|
|
600
|
+
<div className="actions">
|
|
601
|
+
<button className="ghost" disabled={!canUseRich} onClick={() => editor?.chain().focus().undo().run()}>Undo</button>
|
|
602
|
+
<button className="ghost" disabled={!canUseRich} onClick={() => editor?.chain().focus().redo().run()}>Redo</button>
|
|
603
|
+
<button className="ghost" disabled={!canUseRich} onClick={setLink}>Link</button>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
<div className="canvas-wrap">
|
|
608
|
+
{mode !== "raw" && canUseRich && editor && (
|
|
609
|
+
<section className="page-area" ref={pageAreaRef} onScroll={handlePreviewScroll}>
|
|
610
|
+
<article className="page rich">
|
|
611
|
+
<p className="page-label">{formatLabel === "NEW" ? "New Markdown draft" : `Converted from ${formatLabel}`}</p>
|
|
612
|
+
<BubbleMenu editor={editor} options={{ placement: "top" }} className="bubble-menu">
|
|
613
|
+
<ToolbarButton label="B" title="Bold" active={editor?.isActive("bold")} onClick={() => editor?.chain().focus().toggleBold().run()} />
|
|
614
|
+
<ToolbarButton label="I" title="Italic" active={editor?.isActive("italic")} onClick={() => editor?.chain().focus().toggleItalic().run()} />
|
|
615
|
+
<ToolbarButton label="S" title="Strike" active={editor?.isActive("strike")} onClick={() => editor?.chain().focus().toggleStrike().run()} />
|
|
616
|
+
<ToolbarButton label="Code" active={editor?.isActive("code")} onClick={() => editor?.chain().focus().toggleCode().run()} />
|
|
617
|
+
<ToolbarButton label="Link" active={editor?.isActive("link")} onClick={setLink} />
|
|
618
|
+
</BubbleMenu>
|
|
619
|
+
<FloatingMenu editor={editor} options={{ placement: "right-start" }} className="floating-menu">
|
|
620
|
+
<ToolbarButton label="H1" onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} />
|
|
621
|
+
<ToolbarButton label="H2" onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
|
|
622
|
+
<ToolbarButton label="List" onClick={() => editor?.chain().focus().toggleBulletList().run()} />
|
|
623
|
+
<ToolbarButton label="Table" onClick={() => editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} />
|
|
624
|
+
<ToolbarButton label="Image" onClick={insertImageFile} />
|
|
625
|
+
</FloatingMenu>
|
|
626
|
+
<EditorContent editor={editor} />
|
|
627
|
+
</article>
|
|
628
|
+
</section>
|
|
629
|
+
)}
|
|
630
|
+
|
|
631
|
+
{mode !== "raw" && !canUseRich && (
|
|
632
|
+
<ReadOnlyMarkdownPreview markdown={markdown} formatLabel={formatLabel} pageAreaRef={pageAreaRef} onScroll={handlePreviewScroll} />
|
|
633
|
+
)}
|
|
634
|
+
|
|
635
|
+
{(mode === "split" || mode === "raw") && (
|
|
636
|
+
<aside className="markdown-pane editable-markdown">
|
|
637
|
+
<div className="markdown-toolbar" aria-label="Markdown formatting" onMouseDown={(event) => event.preventDefault()}>
|
|
638
|
+
<button className={linePrefixActive(/^# /) ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => toggleLinePrefix("# ", /^#{1,6} /)}>H1</button>
|
|
639
|
+
<button className={linePrefixActive(/^## /) ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => toggleLinePrefix("## ", /^#{1,6} /)}>H2</button>
|
|
640
|
+
<button className={markdownSyntaxActive("**") ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => wrapSelection("**", "**", "bold")}>B</button>
|
|
641
|
+
<button className={activeMarkdownSyntax(["*", "_"]) ? "active" : ""} disabled={markdownToolbarDisabled} onClick={toggleItalic}>I</button>
|
|
642
|
+
<button className={linePrefixActive(/^- /) ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => toggleLinePrefix("- ")}>List</button>
|
|
643
|
+
<button className={linePrefixActive(/^> /) ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => toggleLinePrefix("> ")}>Quote</button>
|
|
644
|
+
<button className={markdownSyntaxActive("`") ? "active" : ""} disabled={markdownToolbarDisabled} onClick={() => wrapSelection("`", "`", "code")}>Code</button>
|
|
645
|
+
<button disabled={markdownToolbarDisabled} onClick={() => wrapSelection("[", "](https://example.com)", "link")}>Link</button>
|
|
646
|
+
<button disabled={markdownToolbarDisabled} onClick={() => insertSnippet("", 2)}>Image</button>
|
|
647
|
+
<button disabled={markdownToolbarDisabled} onClick={() => insertSnippet("\n| Column | Value |\n| --- | --- |\n| Item | Detail |\n", 3)}>Table</button>
|
|
648
|
+
</div>
|
|
649
|
+
<CodeMirror
|
|
650
|
+
className="source code-source"
|
|
651
|
+
value={markdown}
|
|
652
|
+
height="100%"
|
|
653
|
+
basicSetup={{
|
|
654
|
+
lineNumbers: true,
|
|
655
|
+
foldGutter: true,
|
|
656
|
+
highlightActiveLine: true,
|
|
657
|
+
highlightSelectionMatches: true,
|
|
658
|
+
}}
|
|
659
|
+
extensions={codeMirrorExtensions}
|
|
660
|
+
theme={codeMirrorTheme}
|
|
661
|
+
onCreateEditor={(view) => {
|
|
662
|
+
sourceViewRef.current = view;
|
|
663
|
+
const range = view.state.selection.main;
|
|
664
|
+
setSourceSelectionRange({ start: range.from, end: range.to, ready: true });
|
|
665
|
+
setSourceScroller(view.scrollDOM);
|
|
666
|
+
}}
|
|
667
|
+
onUpdate={(viewUpdate) => {
|
|
668
|
+
if (!viewUpdate.selectionSet) return;
|
|
669
|
+
const range = viewUpdate.state.selection.main;
|
|
670
|
+
setSourceSelectionRange({ start: range.from, end: range.to, ready: true });
|
|
671
|
+
}}
|
|
672
|
+
onChange={(value) => syncSource(value)}
|
|
673
|
+
/>
|
|
674
|
+
</aside>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</section>
|
|
678
|
+
|
|
679
|
+
<aside className="side-panel right-panel">
|
|
680
|
+
<h2 className="panel-title">Output Health</h2>
|
|
681
|
+
<div className="source-card">
|
|
682
|
+
<strong>Round-trip confidence</strong>
|
|
683
|
+
<p>Tables, images, links, and headings are tracked as conversion risks.</p>
|
|
684
|
+
<div className="meter" aria-label="Round-trip confidence"><span style={{ width: `${confidence}%` }} /></div>
|
|
685
|
+
</div>
|
|
686
|
+
|
|
687
|
+
<h2 className="panel-title">Table Tools</h2>
|
|
688
|
+
<div className="tool-group">
|
|
689
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().addRowAfter().run()}>Row +</button>
|
|
690
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().addColumnAfter().run()}>Col +</button>
|
|
691
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().deleteRow().run()}>Row -</button>
|
|
692
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().deleteColumn().run()}>Col -</button>
|
|
693
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().toggleHeaderRow().run()}>Header</button>
|
|
694
|
+
<button disabled={!canUseRich || !editor?.isActive("table")} onClick={() => editor?.chain().focus().deleteTable().run()}>Delete</button>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<h2 className="panel-title">Image Tools</h2>
|
|
698
|
+
<div className="tool-group">
|
|
699
|
+
<button disabled={!canUseRich} onClick={insertImageFile}>File</button>
|
|
700
|
+
<button disabled={!canUseRich} onClick={insertImageUrl}>URL</button>
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
<div className="checklist">
|
|
704
|
+
<div className="check"><span className="dot" /><div><b>Markdown target</b><br />{markdownPath || "Choose Save MD As..."}</div></div>
|
|
705
|
+
<div className="check"><span className="dot" /><div><b>Word export</b><br />{exportPath || "Ready to export with template hook."}</div></div>
|
|
706
|
+
<div className="check"><span className={imageCount ? "dot warn" : "dot"} /><div><b>Images</b><br />{imageCount} detected. Asset folder policy still needs final pass.</div></div>
|
|
707
|
+
<div className="check"><span className={tableCount ? "dot warn" : "dot"} /><div><b>Tables</b><br />{tableCount} detected. Advanced cell controls are surfaced here.</div></div>
|
|
708
|
+
</div>
|
|
709
|
+
</aside>
|
|
710
|
+
|
|
711
|
+
<footer className="statusbar">
|
|
712
|
+
<span>{status}{exportPath ? ` - Last Word export: ${exportPath}` : ""}</span>
|
|
713
|
+
<span>Words {wordCount} | Tables {tableCount} | Images {imageCount} | Links {linkCount}</span>
|
|
714
|
+
</footer>
|
|
715
|
+
</main>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
// Compose mode — WezTerm pop-up prompt editor.
|
|
721
|
+
// Launched with `--compose --pane-id <id>`. A blank scratch document with
|
|
722
|
+
// voice dictation (block-tracked), drag-drop image/file → local path insertion,
|
|
723
|
+
// and a "Send to terminal" action that bracket-pastes plain UTF-8 into the
|
|
724
|
+
// captured WezTerm pane (no Enter — the user reviews and submits).
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
function ComposeApp({ paneId, dropDir }) {
|
|
728
|
+
const textareaRef = React.useRef(null);
|
|
729
|
+
const [status, setStatus] = React.useState("Ready — dictate, type, paste, or drop files.");
|
|
730
|
+
const [voiceState, setVoiceState] = React.useState("Voice idle");
|
|
731
|
+
const [recognizing, setRecognizing] = React.useState(false);
|
|
732
|
+
const [blockCount, setBlockCount] = React.useState(0);
|
|
733
|
+
const [sending, setSending] = React.useState(false);
|
|
734
|
+
const [dragging, setDragging] = React.useState(false);
|
|
735
|
+
|
|
736
|
+
// Block tracking: each dictation/insertion segment separated by a pause is a
|
|
737
|
+
// "block". "Delete last block" removes the most recently inserted block.
|
|
738
|
+
const snapshotRef = React.useRef("");
|
|
739
|
+
const blocksRef = React.useRef([]);
|
|
740
|
+
const pendingBlockRef = React.useRef(null);
|
|
741
|
+
const pauseTimerRef = React.useRef(null);
|
|
742
|
+
const recognitionRef = React.useRef(null);
|
|
743
|
+
|
|
744
|
+
const getValue = () => textareaRef.current?.value ?? "";
|
|
745
|
+
const setValue = (next) => {
|
|
746
|
+
if (textareaRef.current) textareaRef.current.value = next;
|
|
747
|
+
snapshotRef.current = next;
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const refreshBlockCount = React.useCallback(() => {
|
|
751
|
+
setBlockCount(blocksRef.current.length);
|
|
752
|
+
}, []);
|
|
753
|
+
|
|
754
|
+
function replaceRange(start, end, text) {
|
|
755
|
+
const el = textareaRef.current;
|
|
756
|
+
if (!el) return;
|
|
757
|
+
const value = el.value;
|
|
758
|
+
el.value = value.slice(0, start) + text + value.slice(end);
|
|
759
|
+
const pos = start + text.length;
|
|
760
|
+
el.selectionStart = pos;
|
|
761
|
+
el.selectionEnd = pos;
|
|
762
|
+
snapshotRef.current = el.value;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function insertText(text, source = "manual") {
|
|
766
|
+
const el = textareaRef.current;
|
|
767
|
+
if (!el) return;
|
|
768
|
+
const start = el.selectionStart ?? el.value.length;
|
|
769
|
+
const end = el.selectionEnd ?? start;
|
|
770
|
+
replaceRange(start, end, text);
|
|
771
|
+
if (text.trim()) {
|
|
772
|
+
blocksRef.current.push({ start, end: start + text.length, text, source, at: Date.now() });
|
|
773
|
+
refreshBlockCount();
|
|
774
|
+
}
|
|
775
|
+
el.focus();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function commonDiff(before, after) {
|
|
779
|
+
let start = 0;
|
|
780
|
+
while (start < before.length && start < after.length && before[start] === after[start]) start += 1;
|
|
781
|
+
let beforeEnd = before.length;
|
|
782
|
+
let afterEnd = after.length;
|
|
783
|
+
while (beforeEnd > start && afterEnd > start && before[beforeEnd - 1] === after[afterEnd - 1]) {
|
|
784
|
+
beforeEnd -= 1;
|
|
785
|
+
afterEnd -= 1;
|
|
786
|
+
}
|
|
787
|
+
return { start, removed: before.slice(start, beforeEnd), inserted: after.slice(start, afterEnd) };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function finalizePendingBlock() {
|
|
791
|
+
const pending = pendingBlockRef.current;
|
|
792
|
+
if (pending && pending.text.trim()) {
|
|
793
|
+
blocksRef.current.push({ ...pending, end: pending.start + pending.text.length, at: Date.now() });
|
|
794
|
+
refreshBlockCount();
|
|
795
|
+
}
|
|
796
|
+
pendingBlockRef.current = null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function handleInput() {
|
|
800
|
+
const current = getValue();
|
|
801
|
+
const diff = commonDiff(snapshotRef.current, current);
|
|
802
|
+
if (diff.inserted) {
|
|
803
|
+
if (!pendingBlockRef.current) {
|
|
804
|
+
pendingBlockRef.current = { start: diff.start, text: diff.inserted, source: "type" };
|
|
805
|
+
} else {
|
|
806
|
+
pendingBlockRef.current.text += diff.inserted;
|
|
807
|
+
}
|
|
808
|
+
clearTimeout(pauseTimerRef.current);
|
|
809
|
+
pauseTimerRef.current = setTimeout(finalizePendingBlock, 1300);
|
|
810
|
+
}
|
|
811
|
+
snapshotRef.current = current;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function deleteLastBlock() {
|
|
815
|
+
finalizePendingBlock();
|
|
816
|
+
const block = blocksRef.current.pop();
|
|
817
|
+
refreshBlockCount();
|
|
818
|
+
if (!block) {
|
|
819
|
+
setStatus("No blocks to delete.");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const value = getValue();
|
|
823
|
+
const idx = value.lastIndexOf(block.text);
|
|
824
|
+
if (idx >= 0) {
|
|
825
|
+
replaceRange(idx, idx + block.text.length, "");
|
|
826
|
+
setStatus(`Deleted last block (${block.source}).`);
|
|
827
|
+
}
|
|
828
|
+
textareaRef.current?.focus();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function clearAll() {
|
|
832
|
+
setValue("");
|
|
833
|
+
pendingBlockRef.current = null;
|
|
834
|
+
blocksRef.current = [];
|
|
835
|
+
refreshBlockCount();
|
|
836
|
+
setStatus("Cleared.");
|
|
837
|
+
textareaRef.current?.focus();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function handleDrop(event) {
|
|
841
|
+
event.preventDefault();
|
|
842
|
+
setDragging(false);
|
|
843
|
+
const files = [...(event.dataTransfer?.files || [])];
|
|
844
|
+
const text = event.dataTransfer?.getData("text/plain");
|
|
845
|
+
if (files.length === 0 && text) {
|
|
846
|
+
insertText(text, "drop");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
for (const file of files) {
|
|
850
|
+
try {
|
|
851
|
+
setStatus(`Saving ${file.name}...`);
|
|
852
|
+
const base64 = await fileToBase64(file);
|
|
853
|
+
const info = await bridge.call("saveDroppedFile", {
|
|
854
|
+
fileName: file.name || "drop.bin",
|
|
855
|
+
mimeType: file.type || "application/octet-stream",
|
|
856
|
+
data: base64,
|
|
857
|
+
});
|
|
858
|
+
const snippet = info.isImage
|
|
859
|
+
? `\n[Image saved locally]\n${info.path}\n`
|
|
860
|
+
: `\n[File saved locally]\n${info.path}\n`;
|
|
861
|
+
insertText(snippet, info.isImage ? "image-drop" : "file-drop");
|
|
862
|
+
setStatus(`Inserted local path for ${file.name}.`);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
setStatus(`Drop failed: ${error.message}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function fileToBase64(file) {
|
|
870
|
+
return new Promise((resolve, reject) => {
|
|
871
|
+
const reader = new FileReader();
|
|
872
|
+
reader.onload = () => {
|
|
873
|
+
const result = String(reader.result || "");
|
|
874
|
+
const comma = result.indexOf(",");
|
|
875
|
+
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
|
876
|
+
};
|
|
877
|
+
reader.onerror = () => reject(reader.error || new Error("Could not read file."));
|
|
878
|
+
reader.readAsDataURL(file);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function setupVoice() {
|
|
883
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
884
|
+
if (!SpeechRecognition) {
|
|
885
|
+
setVoiceState("Speech API unavailable — Windows dictation (Win+H) still works.");
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const recognition = new SpeechRecognition();
|
|
889
|
+
recognition.continuous = true;
|
|
890
|
+
recognition.interimResults = false;
|
|
891
|
+
recognition.lang = "en-US";
|
|
892
|
+
recognition.onstart = () => {
|
|
893
|
+
setRecognizing(true);
|
|
894
|
+
setVoiceState("Voice listening...");
|
|
895
|
+
};
|
|
896
|
+
recognition.onend = () => {
|
|
897
|
+
setRecognizing(false);
|
|
898
|
+
setVoiceState("Voice idle");
|
|
899
|
+
// Each utterance boundary finalizes a dictation block.
|
|
900
|
+
finalizePendingBlock();
|
|
901
|
+
};
|
|
902
|
+
recognition.onerror = (event) => setVoiceState(`Voice error: ${event.error}`);
|
|
903
|
+
recognition.onresult = (event) => {
|
|
904
|
+
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
905
|
+
if (event.results[i].isFinal) {
|
|
906
|
+
const transcript = event.results[i][0].transcript.trim();
|
|
907
|
+
if (transcript) {
|
|
908
|
+
// A dictated utterance is its own block (do not merge via diff).
|
|
909
|
+
finalizePendingBlock();
|
|
910
|
+
insertText(`${transcript} `, "voice");
|
|
911
|
+
finalizePendingBlock();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
return recognition;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
React.useEffect(() => {
|
|
920
|
+
recognitionRef.current = setupVoice();
|
|
921
|
+
textareaRef.current?.focus();
|
|
922
|
+
return () => {
|
|
923
|
+
try {
|
|
924
|
+
recognitionRef.current?.stop();
|
|
925
|
+
} catch {
|
|
926
|
+
/* ignore */
|
|
927
|
+
}
|
|
928
|
+
clearTimeout(pauseTimerRef.current);
|
|
929
|
+
};
|
|
930
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
931
|
+
}, []);
|
|
932
|
+
|
|
933
|
+
function toggleVoice() {
|
|
934
|
+
const recognition = recognitionRef.current;
|
|
935
|
+
if (!recognition) return;
|
|
936
|
+
if (recognizing) recognition.stop();
|
|
937
|
+
else recognition.start();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function send() {
|
|
941
|
+
finalizePendingBlock();
|
|
942
|
+
const content = getValue();
|
|
943
|
+
if (!content.trim()) {
|
|
944
|
+
setStatus("Nothing to send.");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
setSending(true);
|
|
948
|
+
setStatus("Sending to terminal...");
|
|
949
|
+
try {
|
|
950
|
+
const result = await bridge.call("sendToPane", { content, paneId });
|
|
951
|
+
setStatus(`Pasted into pane ${result.paneId}. Review in the terminal, then press Enter to submit.`);
|
|
952
|
+
// Close the pop-up after a successful paste so focus returns to the pane.
|
|
953
|
+
setTimeout(() => window.close(), 200);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
setStatus(`Send failed: ${error.message}`);
|
|
956
|
+
setSending(false);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Keyboard: Ctrl+Enter sends, Ctrl+Backspace-style shortcut for delete block.
|
|
961
|
+
function handleKeyDown(event) {
|
|
962
|
+
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
963
|
+
event.preventDefault();
|
|
964
|
+
send();
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return (
|
|
969
|
+
<main className="compose-shell" data-theme="dark">
|
|
970
|
+
<header className="compose-topbar">
|
|
971
|
+
<div className="compose-title">
|
|
972
|
+
<strong>WezTerm Prompt Editor</strong>
|
|
973
|
+
<span>{paneId ? `Pane ${paneId}` : "No pane bound"}{dropDir ? ` · drops → ${dropDir}` : ""}</span>
|
|
974
|
+
</div>
|
|
975
|
+
<div className="compose-actions">
|
|
976
|
+
<button type="button" className={recognizing ? "active" : ""} onClick={toggleVoice}>
|
|
977
|
+
{recognizing ? "Stop Voice" : "Start Voice"}
|
|
978
|
+
</button>
|
|
979
|
+
<button type="button" onClick={deleteLastBlock}>Delete Last Block</button>
|
|
980
|
+
<button type="button" onClick={clearAll}>Clear</button>
|
|
981
|
+
<button type="button" className="primary" onClick={send} disabled={sending}>
|
|
982
|
+
{sending ? "Sending..." : "Send to terminal"}
|
|
983
|
+
</button>
|
|
984
|
+
</div>
|
|
985
|
+
</header>
|
|
986
|
+
|
|
987
|
+
<section
|
|
988
|
+
className={`compose-editor-wrap${dragging ? " dragging" : ""}`}
|
|
989
|
+
onDragOver={(event) => {
|
|
990
|
+
event.preventDefault();
|
|
991
|
+
setDragging(true);
|
|
992
|
+
}}
|
|
993
|
+
onDragLeave={() => setDragging(false)}
|
|
994
|
+
onDrop={handleDrop}
|
|
995
|
+
>
|
|
996
|
+
<textarea
|
|
997
|
+
ref={textareaRef}
|
|
998
|
+
className="compose-editor"
|
|
999
|
+
spellCheck
|
|
1000
|
+
autoFocus
|
|
1001
|
+
placeholder="Dictate, type, paste, or drop files here. Images are saved to a temp folder and inserted as a local path. Ctrl+Enter sends."
|
|
1002
|
+
onInput={handleInput}
|
|
1003
|
+
onKeyDown={handleKeyDown}
|
|
1004
|
+
/>
|
|
1005
|
+
</section>
|
|
1006
|
+
|
|
1007
|
+
<footer className="compose-statusbar">
|
|
1008
|
+
<span>{status}</span>
|
|
1009
|
+
<span>{voiceState}</span>
|
|
1010
|
+
<span>{blockCount} block{blockCount === 1 ? "" : "s"}</span>
|
|
1011
|
+
<span>regen-mde v{APP_VERSION}</span>
|
|
1012
|
+
</footer>
|
|
1013
|
+
</main>
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Decide which surface to mount: compose mode (pop-up prompt editor) or the
|
|
1018
|
+
// full document editor. We ask the native host before rendering so we never
|
|
1019
|
+
// flash the wrong UI.
|
|
1020
|
+
function bootstrap() {
|
|
1021
|
+
const rootEl = document.getElementById("root");
|
|
1022
|
+
const root = createRoot(rootEl);
|
|
1023
|
+
|
|
1024
|
+
const mountFull = () => root.render(<App />);
|
|
1025
|
+
|
|
1026
|
+
if (!window.chrome?.webview) {
|
|
1027
|
+
// Running outside the native host (e.g. dev) — default to the full editor.
|
|
1028
|
+
mountFull();
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
bridge
|
|
1033
|
+
.call("composeInfo")
|
|
1034
|
+
.then((info) => {
|
|
1035
|
+
if (info?.composeMode) {
|
|
1036
|
+
root.render(<ComposeApp paneId={info.paneId || ""} dropDir={info.dropDir || ""} />);
|
|
1037
|
+
} else {
|
|
1038
|
+
mountFull();
|
|
1039
|
+
}
|
|
1040
|
+
})
|
|
1041
|
+
.catch(() => mountFull());
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
bootstrap();
|