@bendyline/squisq-editor-react 2.0.0 → 2.0.1
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/index.d.ts +75 -33
- package/dist/index.js +1717 -935
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +145 -5
- package/package.json +4 -4
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorShell.tsx +1 -1
- package/src/PreviewControls.tsx +57 -24
- package/src/RecorderEntry.tsx +2 -0
- package/src/Toolbar.tsx +164 -19
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/previewControls.test.tsx +164 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +48 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/codeContext/types.ts +1 -1
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +16 -2
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +7 -1
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/editor.css +30 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
package/src/Toolbar.tsx
CHANGED
|
@@ -52,6 +52,13 @@ import type { PickerEntry } from './emojiData';
|
|
|
52
52
|
import { createPortal } from 'react-dom';
|
|
53
53
|
import { PreviewModeMenu, displayModeLabel, usePreviewSettingsOptional } from './PreviewControls';
|
|
54
54
|
import { filterVisibleMediaEntries } from './mediaEntries';
|
|
55
|
+
import {
|
|
56
|
+
selectionToTable,
|
|
57
|
+
selectionToTableMarkdown,
|
|
58
|
+
selectionToTaskItems,
|
|
59
|
+
selectionToTaskListMarkdown,
|
|
60
|
+
type SelectionTaskItem,
|
|
61
|
+
} from './selectionConversions';
|
|
55
62
|
|
|
56
63
|
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
57
64
|
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
@@ -273,6 +280,7 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
273
280
|
|
|
274
281
|
const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
|
|
275
282
|
const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
|
|
283
|
+
const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
|
|
276
284
|
const INSERT_MENU_WIDTH = 200;
|
|
277
285
|
const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
|
|
278
286
|
const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
|
|
@@ -297,18 +305,35 @@ function fileCountBadge(count: number): string {
|
|
|
297
305
|
return count > 99 ? '99+' : String(count);
|
|
298
306
|
}
|
|
299
307
|
|
|
300
|
-
function
|
|
308
|
+
function paragraphContent(text: string): JSONContent {
|
|
309
|
+
return text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function tableContent(rows: string[][]): JSONContent {
|
|
313
|
+
return {
|
|
314
|
+
type: 'table',
|
|
315
|
+
content: rows.map((row, rowIndex) => ({
|
|
316
|
+
type: 'tableRow',
|
|
317
|
+
content: row.map((cell) => ({
|
|
318
|
+
type: rowIndex === 0 ? 'tableHeader' : 'tableCell',
|
|
319
|
+
content: [paragraphContent(cell)],
|
|
320
|
+
})),
|
|
321
|
+
})),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function taskListContent(
|
|
326
|
+
items: readonly SelectionTaskItem[] = TASK_LIST_ITEMS.map((text) => ({
|
|
327
|
+
checked: false,
|
|
328
|
+
text,
|
|
329
|
+
})),
|
|
330
|
+
): JSONContent {
|
|
301
331
|
return {
|
|
302
332
|
type: 'taskList',
|
|
303
|
-
content:
|
|
333
|
+
content: items.map((item) => ({
|
|
304
334
|
type: 'taskItem',
|
|
305
|
-
attrs: { checked:
|
|
306
|
-
content: [
|
|
307
|
-
{
|
|
308
|
-
type: 'paragraph',
|
|
309
|
-
content: [{ type: 'text', text: item }],
|
|
310
|
-
},
|
|
311
|
-
],
|
|
335
|
+
attrs: { checked: item.checked },
|
|
336
|
+
content: [paragraphContent(item.text)],
|
|
312
337
|
})),
|
|
313
338
|
};
|
|
314
339
|
}
|
|
@@ -319,6 +344,26 @@ function insertTaskList(editor: TiptapEditor): void {
|
|
|
319
344
|
editor.chain().focus().insertContent(content).run();
|
|
320
345
|
}
|
|
321
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Expand a full-line Tiptap text selection to the surrounding top-level node
|
|
349
|
+
* boundaries. Inserting a list/table at an in-paragraph range makes
|
|
350
|
+
* ProseMirror preserve the emptied paragraph before or after the new block.
|
|
351
|
+
* Partial-line selections deliberately retain their exact range.
|
|
352
|
+
*/
|
|
353
|
+
function blockConversionRange(editor: TiptapEditor): { from: number; to: number } {
|
|
354
|
+
const { from, to, $from, $to } = editor.state.selection;
|
|
355
|
+
return {
|
|
356
|
+
from:
|
|
357
|
+
$from.depth === 1 && $from.parent.isTextblock && $from.parentOffset === 0
|
|
358
|
+
? $from.before(1)
|
|
359
|
+
: from,
|
|
360
|
+
to:
|
|
361
|
+
$to.depth === 1 && $to.parent.isTextblock && $to.parentOffset === $to.parent.content.size
|
|
362
|
+
? $to.after(1)
|
|
363
|
+
: to,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
322
367
|
// ─── Tiptap active-state map ────────────────────────────
|
|
323
368
|
|
|
324
369
|
/** Returns true if the given button id is currently active in Tiptap */
|
|
@@ -377,17 +422,23 @@ const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text
|
|
|
377
422
|
* fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
|
|
378
423
|
* the interactive canvas over it). This exact art is the codec's own
|
|
379
424
|
* canonical rendering of a two-node flow, so it re-parses and re-renders
|
|
380
|
-
* byte-stably.
|
|
425
|
+
* byte-stably. The leading rows/columns are deliberate: ASCII has no
|
|
426
|
+
* separate canvas-origin metadata, so this persisted gutter gives the first
|
|
427
|
+
* node real grid space to move north/west. Without it, content-fit makes the
|
|
428
|
+
* node look centered while its authored coordinate is still (0, 0), and a
|
|
429
|
+
* drag into the apparent margin clamps straight back to that corner.
|
|
381
430
|
*/
|
|
382
431
|
const DIAGRAM_STARTER_ART = [
|
|
383
|
-
'
|
|
384
|
-
'
|
|
385
|
-
'
|
|
386
|
-
'
|
|
387
|
-
'
|
|
388
|
-
'
|
|
389
|
-
'
|
|
390
|
-
'
|
|
432
|
+
'',
|
|
433
|
+
'',
|
|
434
|
+
' ┌─────────┐',
|
|
435
|
+
' │ Start │',
|
|
436
|
+
' └────┬────┘',
|
|
437
|
+
' │',
|
|
438
|
+
' ▼',
|
|
439
|
+
' ┌────┴────┐',
|
|
440
|
+
' │ Next │',
|
|
441
|
+
' └─────────┘',
|
|
391
442
|
].join('\n');
|
|
392
443
|
/**
|
|
393
444
|
* Fenced form for raw / code views — tagged with the explicit `diagram`
|
|
@@ -1235,6 +1286,68 @@ export function Toolbar({
|
|
|
1235
1286
|
[monacoEditor, markdownSource, setMarkdownSource],
|
|
1236
1287
|
);
|
|
1237
1288
|
|
|
1289
|
+
// ── Selected-text conversion handlers ─────────────────
|
|
1290
|
+
const readSelectedText = useCallback((): string => {
|
|
1291
|
+
// Canvas text overlays intentionally have a smaller schema without tables
|
|
1292
|
+
// or task lists. Ignore their selection instead of surfacing actions that
|
|
1293
|
+
// cannot be represented by those layers.
|
|
1294
|
+
if (activeSceneText) return '';
|
|
1295
|
+
|
|
1296
|
+
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1297
|
+
const { from, to, empty } = tiptapEditor.state.selection;
|
|
1298
|
+
return empty ? '' : tiptapEditor.state.doc.textBetween(from, to, '\n');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (activeView === 'raw' && monacoEditor) {
|
|
1302
|
+
const selection = monacoEditor.getSelection();
|
|
1303
|
+
const model = monacoEditor.getModel();
|
|
1304
|
+
return selection && model ? model.getValueInRange(selection) : '';
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return '';
|
|
1308
|
+
}, [activeSceneText, activeView, monacoEditor, tiptapEditor]);
|
|
1309
|
+
|
|
1310
|
+
const handleConvertSelection = useCallback(
|
|
1311
|
+
(target: 'table' | 'tasklist') => {
|
|
1312
|
+
const selectedText = readSelectedText();
|
|
1313
|
+
if (!selectedText.trim()) return;
|
|
1314
|
+
|
|
1315
|
+
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1316
|
+
const content =
|
|
1317
|
+
target === 'table'
|
|
1318
|
+
? tableContent(selectionToTable(selectedText).rows)
|
|
1319
|
+
: taskListContent(selectionToTaskItems(selectedText));
|
|
1320
|
+
tiptapEditor
|
|
1321
|
+
.chain()
|
|
1322
|
+
.focus()
|
|
1323
|
+
.insertContentAt(blockConversionRange(tiptapEditor), content)
|
|
1324
|
+
.run();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (activeView === 'raw' && monacoEditor) {
|
|
1329
|
+
const selection = monacoEditor.getSelection();
|
|
1330
|
+
if (!selection) return;
|
|
1331
|
+
const converted =
|
|
1332
|
+
target === 'table'
|
|
1333
|
+
? selectionToTableMarkdown(selectedText)
|
|
1334
|
+
: selectionToTaskListMarkdown(selectedText);
|
|
1335
|
+
if (!converted) return;
|
|
1336
|
+
|
|
1337
|
+
// Monaco line selections commonly include the newline after the last
|
|
1338
|
+
// selected line. Retain selected edge newlines so the replacement
|
|
1339
|
+
// block never runs into the surrounding Markdown.
|
|
1340
|
+
const leadingNewline = /^(?:\r?\n)/.test(selectedText) ? '\n' : '';
|
|
1341
|
+
const trailingNewline = /(?:\r?\n)$/.test(selectedText) ? '\n' : '';
|
|
1342
|
+
monacoEditor.executeEdits('toolbar-convert-selection', [
|
|
1343
|
+
{ range: selection, text: leadingNewline + converted + trailingNewline },
|
|
1344
|
+
]);
|
|
1345
|
+
monacoEditor.focus();
|
|
1346
|
+
}
|
|
1347
|
+
},
|
|
1348
|
+
[activeView, monacoEditor, readSelectedText, tiptapEditor],
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1238
1351
|
// ── Image upload handler ───────────────────────────────
|
|
1239
1352
|
const handleImageFile = useCallback(
|
|
1240
1353
|
async (file: File) => {
|
|
@@ -1471,6 +1584,10 @@ export function Toolbar({
|
|
|
1471
1584
|
if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
|
|
1472
1585
|
return INLINE_SCENE_BUTTONS.has(id);
|
|
1473
1586
|
};
|
|
1587
|
+
const showConvertActions =
|
|
1588
|
+
insertMenuAnchor !== null &&
|
|
1589
|
+
readSelectedText().trim().length > 0 &&
|
|
1590
|
+
CONVERT_BUTTONS.some((button) => buttonAllowed(button.id));
|
|
1474
1591
|
|
|
1475
1592
|
// ── Progressive heading disclosure ───────────────────
|
|
1476
1593
|
// H1\u2013H3 are always visible. H4 appears once the document already
|
|
@@ -2392,7 +2509,35 @@ export function Toolbar({
|
|
|
2392
2509
|
style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
|
|
2393
2510
|
role="menu"
|
|
2394
2511
|
>
|
|
2395
|
-
|
|
2512
|
+
{showConvertActions && (
|
|
2513
|
+
<>
|
|
2514
|
+
<div className="squisq-insert-menu-header">Convert</div>
|
|
2515
|
+
{CONVERT_BUTTONS.map((btn) => {
|
|
2516
|
+
const label = btn.id === 'table' ? 'Table' : 'Task List';
|
|
2517
|
+
return (
|
|
2518
|
+
<button
|
|
2519
|
+
key={`convert-${btn.id}`}
|
|
2520
|
+
className="squisq-toolbar-overflow-item"
|
|
2521
|
+
disabled={!buttonAllowed(btn.id)}
|
|
2522
|
+
onClick={() => {
|
|
2523
|
+
handleConvertSelection(btn.id as 'table' | 'tasklist');
|
|
2524
|
+
closeInsertMenu();
|
|
2525
|
+
}}
|
|
2526
|
+
role="menuitem"
|
|
2527
|
+
aria-label={`Convert selection to ${label}`}
|
|
2528
|
+
>
|
|
2529
|
+
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
2530
|
+
<span>{label}</span>
|
|
2531
|
+
</button>
|
|
2532
|
+
);
|
|
2533
|
+
})}
|
|
2534
|
+
</>
|
|
2535
|
+
)}
|
|
2536
|
+
<div
|
|
2537
|
+
className={`squisq-insert-menu-header${showConvertActions ? ' squisq-insert-menu-header--separated' : ''}`}
|
|
2538
|
+
>
|
|
2539
|
+
Insert
|
|
2540
|
+
</div>
|
|
2396
2541
|
{MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
|
|
2397
2542
|
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
2398
2543
|
const stripped = btn.title.replace(/^Insert\s+/i, '');
|
|
@@ -10,7 +10,7 @@ const noop = () => {};
|
|
|
10
10
|
const baseSection = {
|
|
11
11
|
id: 'foo@10',
|
|
12
12
|
summaryMarkdown: '**foo** — does things · ↓2 imported-by',
|
|
13
|
-
markdown: 'Body text with [`a.ts`](
|
|
13
|
+
markdown: 'Body text with [`a.ts`](workspace-nav:src%2Fa.ts) and [line 4](#L4).',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]> = {}) {
|
|
@@ -18,7 +18,7 @@ function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]>
|
|
|
18
18
|
section: baseSection,
|
|
19
19
|
expanded: false,
|
|
20
20
|
onToggle: vi.fn(),
|
|
21
|
-
linkSchemes: ['
|
|
21
|
+
linkSchemes: ['workspace-nav'] as const,
|
|
22
22
|
onLinkClick: vi.fn(),
|
|
23
23
|
onRevealLine: vi.fn(),
|
|
24
24
|
onMeasure: noop,
|
|
@@ -47,7 +47,7 @@ describe('<CodeContextSectionView>', () => {
|
|
|
47
47
|
const body = container.querySelector('.squisq-ccx-body')!;
|
|
48
48
|
expect(body.textContent).toContain('Body text');
|
|
49
49
|
const anchors = [...body.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
50
|
-
expect(anchors).toContain('
|
|
50
|
+
expect(anchors).toContain('workspace-nav:src%2Fa.ts');
|
|
51
51
|
expect(anchors).toContain('#L4');
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -63,10 +63,12 @@ describe('<CodeContextSectionView>', () => {
|
|
|
63
63
|
const onLinkClick = vi.fn(() => undefined);
|
|
64
64
|
const { container } = renderView({ expanded: true, onLinkClick });
|
|
65
65
|
const nav = [...container.querySelectorAll('a')].find(
|
|
66
|
-
(a) => a.getAttribute('href') === '
|
|
66
|
+
(a) => a.getAttribute('href') === 'workspace-nav:src%2Fa.ts',
|
|
67
67
|
)!;
|
|
68
68
|
const first = fireEvent.click(nav);
|
|
69
|
-
expect(onLinkClick).toHaveBeenCalledWith('
|
|
69
|
+
expect(onLinkClick).toHaveBeenCalledWith('workspace-nav:src%2Fa.ts', {
|
|
70
|
+
sectionId: 'foo@10',
|
|
71
|
+
});
|
|
70
72
|
expect(first).toBe(false); // preventDefault was called
|
|
71
73
|
|
|
72
74
|
onLinkClick.mockReturnValue(false as unknown as undefined);
|
|
@@ -89,7 +91,7 @@ describe('<CodeContextSectionView>', () => {
|
|
|
89
91
|
it('without linkSchemes, custom-scheme links render blocked (no anchor)', () => {
|
|
90
92
|
const { container } = renderView({ expanded: true, linkSchemes: undefined });
|
|
91
93
|
const anchors = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
92
|
-
expect(anchors).not.toContain('
|
|
94
|
+
expect(anchors).not.toContain('workspace-nav:src%2Fa.ts');
|
|
93
95
|
expect(container.querySelector('.squisq-md-link--blocked')).toBeTruthy();
|
|
94
96
|
});
|
|
95
97
|
});
|
|
@@ -123,6 +123,28 @@ describe('DocumentSettingsDialog', () => {
|
|
|
123
123
|
expect(next).not.toContain('squisq-theme');
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('removes explicit managed defaults and their legacy aliases on save', () => {
|
|
127
|
+
const onSave = vi.fn();
|
|
128
|
+
const src = `---
|
|
129
|
+
squisq-theme: standard
|
|
130
|
+
themeId: standard
|
|
131
|
+
theme: standard
|
|
132
|
+
squisq-captions: standard
|
|
133
|
+
caption-style: standard
|
|
134
|
+
author: Keep
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
# Doc
|
|
138
|
+
`;
|
|
139
|
+
open(src, onSave);
|
|
140
|
+
clickSave();
|
|
141
|
+
|
|
142
|
+
const next = onSave.mock.calls[0][0] as string;
|
|
143
|
+
expect(next).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
|
|
144
|
+
expect(next).not.toMatch(/^(?:squisq-captions|caption-style):/m);
|
|
145
|
+
expect(next).toContain('author: Keep');
|
|
146
|
+
});
|
|
147
|
+
|
|
126
148
|
it('writes squisq-transform when a transform is picked', () => {
|
|
127
149
|
const onSave = vi.fn();
|
|
128
150
|
open('# Doc\n', onSave);
|
|
@@ -7,7 +7,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
|
7
7
|
* uploaded files into the bin without inserting a markdown ref into
|
|
8
8
|
* the editor body. A user would upload an image, hit Send in the
|
|
9
9
|
* downstream chat composer, and the outgoing markdown would have no
|
|
10
|
-
* image reference — the
|
|
10
|
+
* image reference — the downstream consumer would reply "nothing came through."
|
|
11
11
|
*
|
|
12
12
|
* The fix: after `mediaProvider.addMedia(...)` succeeds, MediaBin
|
|
13
13
|
* fires `onMediaUploaded(relativePath, name, mimeType)`. The
|
|
@@ -18,7 +18,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
|
18
18
|
* These tests exercise the contract directly: the markdown snippet
|
|
19
19
|
* produced by the upload callback, once round-tripped through the
|
|
20
20
|
* editor's markdown↔HTML bridge, must round-trip back to a form
|
|
21
|
-
* the
|
|
21
|
+
* the downstream service's image-extraction regex can see.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
function fakeMediaProvider(records: string[]): MediaProvider {
|
|
@@ -88,6 +88,75 @@ function LibraryThemeHarness() {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function CoverSlideProbe() {
|
|
92
|
+
const { activeCoverSlide, setCoverSlideEnabled } = usePreviewSettings();
|
|
93
|
+
const { markdownSource } = useEditorContext();
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<button type="button" onClick={() => setCoverSlideEnabled(false)}>
|
|
97
|
+
Hide cover slide
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" onClick={() => setCoverSlideEnabled(true)}>
|
|
100
|
+
Use default cover slide
|
|
101
|
+
</button>
|
|
102
|
+
<div data-testid="active-cover-slide">{String(activeCoverSlide)}</div>
|
|
103
|
+
<pre data-testid="markdown-source">{markdownSource}</pre>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function CoverSlideHarness() {
|
|
109
|
+
const { doc } = useEditorContext();
|
|
110
|
+
return (
|
|
111
|
+
<PreviewSettingsProvider doc={doc}>
|
|
112
|
+
<CoverSlideProbe />
|
|
113
|
+
</PreviewSettingsProvider>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ManagedDefaultsProbe() {
|
|
118
|
+
const {
|
|
119
|
+
activeThemeId,
|
|
120
|
+
activeTransformStyle,
|
|
121
|
+
activeCaptionStyle,
|
|
122
|
+
activeCaptionsEnabled,
|
|
123
|
+
setSelectedThemeId,
|
|
124
|
+
setSelectedTransformStyle,
|
|
125
|
+
setCaptionMode,
|
|
126
|
+
} = usePreviewSettings();
|
|
127
|
+
const { markdownSource } = useEditorContext();
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<button type="button" onClick={() => setSelectedThemeId('standard')}>
|
|
131
|
+
Use default theme
|
|
132
|
+
</button>
|
|
133
|
+
<button type="button" onClick={() => setSelectedTransformStyle('')}>
|
|
134
|
+
Use default transform
|
|
135
|
+
</button>
|
|
136
|
+
<button type="button" onClick={() => setCaptionMode('standard')}>
|
|
137
|
+
Use default captions
|
|
138
|
+
</button>
|
|
139
|
+
<div
|
|
140
|
+
data-testid="managed-defaults"
|
|
141
|
+
data-theme={activeThemeId}
|
|
142
|
+
data-transform={activeTransformStyle}
|
|
143
|
+
data-caption-style={activeCaptionStyle}
|
|
144
|
+
data-captions-enabled={String(activeCaptionsEnabled)}
|
|
145
|
+
/>
|
|
146
|
+
<pre data-testid="markdown-source">{markdownSource}</pre>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ManagedDefaultsHarness() {
|
|
152
|
+
const { doc } = useEditorContext();
|
|
153
|
+
return (
|
|
154
|
+
<PreviewSettingsProvider doc={doc}>
|
|
155
|
+
<ManagedDefaultsProbe />
|
|
156
|
+
</PreviewSettingsProvider>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
91
160
|
function renderPreviewControls(markdown: string) {
|
|
92
161
|
render(
|
|
93
162
|
<EditorProvider initialMarkdown={markdown}>
|
|
@@ -191,6 +260,101 @@ describe('PreviewModeSwitch', () => {
|
|
|
191
260
|
});
|
|
192
261
|
});
|
|
193
262
|
|
|
263
|
+
describe('cover-slide frontmatter', () => {
|
|
264
|
+
it('writes the non-default as a boolean and removes the default value', async () => {
|
|
265
|
+
render(
|
|
266
|
+
<EditorProvider initialMarkdown="# Hello">
|
|
267
|
+
<CoverSlideHarness />
|
|
268
|
+
</EditorProvider>,
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
272
|
+
fireEvent.click(screen.getByRole('button', { name: 'Hide cover slide' }));
|
|
273
|
+
|
|
274
|
+
await waitFor(() => {
|
|
275
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
276
|
+
expect(source).toContain('squisq-cover-slide: false');
|
|
277
|
+
expect(source).not.toContain('squisq-cover-slide: "false"');
|
|
278
|
+
});
|
|
279
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
|
|
280
|
+
|
|
281
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
|
|
282
|
+
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
285
|
+
expect(source).not.toContain('squisq-cover-slide');
|
|
286
|
+
expect(source).not.toContain('---');
|
|
287
|
+
});
|
|
288
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('removes a legacy cover-slide override when restoring the default', async () => {
|
|
292
|
+
render(
|
|
293
|
+
<EditorProvider initialMarkdown={'---\ncover-slide: false\ntitle: Hello\n---\n\n# Hello'}>
|
|
294
|
+
<CoverSlideHarness />
|
|
295
|
+
</EditorProvider>,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('false');
|
|
300
|
+
});
|
|
301
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default cover slide' }));
|
|
302
|
+
|
|
303
|
+
await waitFor(() => {
|
|
304
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
305
|
+
expect(source).not.toContain('cover-slide');
|
|
306
|
+
expect(source).toContain('title: Hello');
|
|
307
|
+
});
|
|
308
|
+
expect(screen.getByTestId('active-cover-slide').textContent).toBe('true');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('managed preview-setting defaults', () => {
|
|
313
|
+
it('removes default theme, transform, and caption values plus their legacy aliases', async () => {
|
|
314
|
+
const markdown = `---
|
|
315
|
+
squisq-theme: documentary
|
|
316
|
+
themeId: bold
|
|
317
|
+
theme: cinematic
|
|
318
|
+
squisq-transform: documentary
|
|
319
|
+
transform-style: magazine
|
|
320
|
+
squisq-captions: social
|
|
321
|
+
caption-style: off
|
|
322
|
+
title: Hello
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
# Hello`;
|
|
326
|
+
render(
|
|
327
|
+
<EditorProvider initialMarkdown={markdown}>
|
|
328
|
+
<ManagedDefaultsHarness />
|
|
329
|
+
</EditorProvider>,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default theme' }));
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
335
|
+
expect(source).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
|
|
336
|
+
});
|
|
337
|
+
expect(screen.getByTestId('managed-defaults').getAttribute('data-theme')).toBe('standard');
|
|
338
|
+
|
|
339
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default transform' }));
|
|
340
|
+
await waitFor(() => {
|
|
341
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
342
|
+
expect(source).not.toMatch(/^(?:squisq-transform|transform-style):/m);
|
|
343
|
+
});
|
|
344
|
+
expect(screen.getByTestId('managed-defaults').getAttribute('data-transform')).toBe('');
|
|
345
|
+
|
|
346
|
+
fireEvent.click(screen.getByRole('button', { name: 'Use default captions' }));
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
const source = screen.getByTestId('markdown-source').textContent ?? '';
|
|
349
|
+
expect(source).not.toMatch(/^(?:squisq-captions|caption-style):/m);
|
|
350
|
+
expect(source).toContain('title: Hello');
|
|
351
|
+
});
|
|
352
|
+
const defaults = screen.getByTestId('managed-defaults');
|
|
353
|
+
expect(defaults.getAttribute('data-caption-style')).toBe('standard');
|
|
354
|
+
expect(defaults.getAttribute('data-captions-enabled')).toBe('true');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
194
358
|
describe('PreviewToolbarControls', () => {
|
|
195
359
|
it('presents transforms as summarization without implying the source is changed', () => {
|
|
196
360
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
7
|
+
import { RecorderModal } from '../recorder/RecorderModal';
|
|
8
|
+
import { RecorderPanel } from '../recorder/RecorderPanel';
|
|
9
|
+
|
|
10
|
+
const mediaProvider: MediaProvider = {
|
|
11
|
+
resolveUrl: vi.fn(async (path: string) => path),
|
|
12
|
+
listMedia: vi.fn(async () => []),
|
|
13
|
+
addMedia: vi.fn(async (name: string) => name),
|
|
14
|
+
removeMedia: vi.fn(async () => undefined),
|
|
15
|
+
dispose: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('recorder theme propagation', () => {
|
|
19
|
+
it('creates a dark theme scope for the recorder dialog', () => {
|
|
20
|
+
render(<RecorderModal mediaProvider={mediaProvider} colorScheme="dark" onClose={vi.fn()} />);
|
|
21
|
+
|
|
22
|
+
const dialog = screen.getByRole('dialog', { name: 'Record media' });
|
|
23
|
+
expect(dialog.getAttribute('data-theme')).toBe('dark');
|
|
24
|
+
expect(dialog.classList.contains('squisq-editor-shell')).toBe(true);
|
|
25
|
+
expect(dialog.style.colorScheme).toBe('dark');
|
|
26
|
+
expect(dialog.style.getPropertyValue('--squisq-recorder-surface')).toBe(
|
|
27
|
+
'var(--squisq-bg, #1f2937)',
|
|
28
|
+
);
|
|
29
|
+
expect(dialog.style.getPropertyValue('--squisq-recorder-text')).toBe(
|
|
30
|
+
'var(--squisq-text, #e5e7eb)',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('passes the requested scheme through the portaled panel wrapper', () => {
|
|
35
|
+
render(<RecorderPanel mediaProvider={mediaProvider} colorScheme="dark" />);
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'Record media' }));
|
|
37
|
+
|
|
38
|
+
expect(screen.getByRole('dialog', { name: 'Record media' }).getAttribute('data-theme')).toBe(
|
|
39
|
+
'dark',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
selectionToTable,
|
|
4
|
+
selectionToTableMarkdown,
|
|
5
|
+
selectionToTaskItems,
|
|
6
|
+
selectionToTaskListMarkdown,
|
|
7
|
+
} from '../selectionConversions';
|
|
8
|
+
|
|
9
|
+
describe('selectionToTable', () => {
|
|
10
|
+
it('detects consistently pipe-delimited rows, including outer pipes', () => {
|
|
11
|
+
expect(selectionToTable('| Name | Role |\n| Ada | Engineer |')).toEqual({
|
|
12
|
+
delimiter: 'pipe',
|
|
13
|
+
rows: [
|
|
14
|
+
['Name', 'Role'],
|
|
15
|
+
['Ada', 'Engineer'],
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('detects CSV rows without splitting quoted commas', () => {
|
|
21
|
+
expect(selectionToTable('Name,Notes\nAda,"Math, logic"')).toEqual({
|
|
22
|
+
delimiter: 'comma',
|
|
23
|
+
rows: [
|
|
24
|
+
['Name', 'Notes'],
|
|
25
|
+
['Ada', 'Math, logic'],
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('retains literal quotes in comma-delimited cells', () => {
|
|
31
|
+
expect(selectionToTable('Item,Size\nDisplay,5" screen').rows[1]).toEqual([
|
|
32
|
+
'Display',
|
|
33
|
+
'5" screen',
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
['tab', 'Name\tRole\nAda\tEngineer'],
|
|
39
|
+
['multispace', 'Name Role\nAda Engineer'],
|
|
40
|
+
] as const)('detects %s-delimited rows', (delimiter, text) => {
|
|
41
|
+
expect(selectionToTable(text)).toEqual({
|
|
42
|
+
delimiter,
|
|
43
|
+
rows: [
|
|
44
|
+
['Name', 'Role'],
|
|
45
|
+
['Ada', 'Engineer'],
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to one column when delimiter counts are inconsistent', () => {
|
|
51
|
+
expect(selectionToTable('One,Two\nThree\nFour,Five,Six')).toEqual({
|
|
52
|
+
delimiter: null,
|
|
53
|
+
rows: [['One,Two'], ['Three'], ['Four,Five,Six']],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses the first row as the Markdown header and escapes cell pipes', () => {
|
|
58
|
+
expect(selectionToTableMarkdown('Name,Notes\nAda,A | B')).toBe(
|
|
59
|
+
'| Name | Notes |\n| --- | --- |\n| Ada | A \\| B |',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('selectionToTaskListMarkdown', () => {
|
|
65
|
+
it('turns non-empty selected lines into unchecked tasks', () => {
|
|
66
|
+
expect(selectionToTaskListMarkdown('Buy milk\n\nCall Sam')).toBe(
|
|
67
|
+
'- [ ] Buy milk\n- [ ] Call Sam',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('normalizes list markers and retains existing checked state', () => {
|
|
72
|
+
const text = '- first\n2. second\n- [x] already done';
|
|
73
|
+
expect(selectionToTaskItems(text)).toEqual([
|
|
74
|
+
{ checked: false, text: 'first' },
|
|
75
|
+
{ checked: false, text: 'second' },
|
|
76
|
+
{ checked: true, text: 'already done' },
|
|
77
|
+
]);
|
|
78
|
+
expect(selectionToTaskListMarkdown(text)).toBe('- [ ] first\n- [ ] second\n- [x] already done');
|
|
79
|
+
});
|
|
80
|
+
});
|