@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.
Files changed (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. 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 taskListContent(): JSONContent {
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: TASK_LIST_ITEMS.map((item) => ({
333
+ content: items.map((item) => ({
304
334
  type: 'taskItem',
305
- attrs: { checked: false },
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
- '│ Start │',
385
- '└────┬────┘',
386
- ' │',
387
- '',
388
- '┌────┴────┐',
389
- '│ Next │',
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
- <div className="squisq-insert-menu-header">Insert</div>
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`](gezel-nav:src%2Fa.ts) and [line 4](#L4).',
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: ['gezel-nav'] as const,
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('gezel-nav:src%2Fa.ts');
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') === 'gezel-nav:src%2Fa.ts',
66
+ (a) => a.getAttribute('href') === 'workspace-nav:src%2Fa.ts',
67
67
  )!;
68
68
  const first = fireEvent.click(nav);
69
- expect(onLinkClick).toHaveBeenCalledWith('gezel-nav:src%2Fa.ts', { sectionId: 'foo@10' });
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('gezel-nav:src%2Fa.ts');
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 gezel would reply "nothing came through."
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 gezel service's image-extraction regex can see.
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
+ });