@bendyline/squisq-editor-react 1.6.1 → 2.0.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.
Files changed (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -24,9 +24,10 @@
24
24
  */
25
25
 
26
26
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
27
- import type { Theme, ThemeSeedColors } from '@bendyline/squisq/schemas';
27
+ import type { CustomTemplateDefinition, Theme, ThemeSeedColors } from '@bendyline/squisq/schemas';
28
28
  import { resolveTheme } from '@bendyline/squisq/schemas';
29
29
  import { ThemePicker } from '../ThemePicker';
30
+ import { ImportThemeSection } from './ImportThemeSection';
30
31
  import {
31
32
  type Draft,
32
33
  type AccentInput,
@@ -49,13 +50,19 @@ import { Section, SeedColorRow, FontPicker, PresetRow, AccentEditor } from './th
49
50
  /** Where a saved theme lands — mirrors `DesignerSaveTarget` for templates. */
50
51
  export type ThemeSaveTarget = 'doc' | 'library';
51
52
 
53
+ /** Extra assets riding along with a theme save (file-import byproducts). */
54
+ export interface ThemeSaveExtras {
55
+ /** Custom layout templates inferred from an imported PPTX. */
56
+ templates?: CustomTemplateDefinition[];
57
+ }
58
+
52
59
  export interface CustomThemeDialogProps {
53
60
  /** Theme being edited, or null to author a new one. */
54
61
  value: Theme | null;
55
62
  /** Fired on every edit with the compiled theme (host previews via themeOverride). */
56
63
  onChange?: (theme: Theme) => void;
57
64
  /** Fired when the user saves, with the chosen target. */
58
- onSave: (theme: Theme, target: ThemeSaveTarget) => void;
65
+ onSave: (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => void;
59
66
  /** Dismiss (Cancel / Esc / close button). */
60
67
  onClose: () => void;
61
68
  }
@@ -70,6 +77,8 @@ function initialDraft(value: Theme | null): Draft {
70
77
 
71
78
  export function CustomThemeDialog({ value, onChange, onSave, onClose }: CustomThemeDialogProps) {
72
79
  const [draft, setDraft] = useState<Draft>(() => initialDraft(value));
80
+ // Layout templates inferred from a file import, saved alongside the theme.
81
+ const [importedLayouts, setImportedLayouts] = useState<CustomTemplateDefinition[] | null>(null);
73
82
 
74
83
  const nameRef = useRef<HTMLInputElement>(null);
75
84
  useEffect(() => {
@@ -117,12 +126,16 @@ export function CustomThemeDialog({ value, onChange, onSave, onClose }: CustomTh
117
126
  const handleSave = useCallback(
118
127
  (target: ThemeSaveTarget) => {
119
128
  try {
120
- onSave(compile(draft), target);
129
+ const extras =
130
+ importedLayouts && importedLayouts.length > 0
131
+ ? { templates: importedLayouts }
132
+ : undefined;
133
+ onSave(compile(draft), target, extras);
121
134
  } catch {
122
135
  // A compile failure here means the draft is invalid; leave the dialog open.
123
136
  }
124
137
  },
125
- [draft, onSave, compile],
138
+ [draft, onSave, compile, importedLayouts],
126
139
  );
127
140
 
128
141
  // Accents show the base's schemes until the user edits them (then the draft's
@@ -175,6 +188,19 @@ export function CustomThemeDialog({ value, onChange, onSave, onClose }: CustomTh
175
188
  />
176
189
  </Section>
177
190
 
191
+ <Section
192
+ title="Import from file"
193
+ hint="Pull colors and fonts from a Word, PowerPoint, or Excel file. PowerPoint slide layouts become custom layouts."
194
+ >
195
+ <ImportThemeSection
196
+ allowLayouts
197
+ onImported={(patch, result) => {
198
+ updateDraft(patch);
199
+ setImportedLayouts(result.layouts ?? null);
200
+ }}
201
+ />
202
+ </Section>
203
+
178
204
  <Section
179
205
  title="Colors"
180
206
  hint="Primary, secondary, and tertiary (accent). The rest is derived."
@@ -269,7 +295,11 @@ export function CustomThemeDialog({ value, onChange, onSave, onClose }: CustomTh
269
295
  type="button"
270
296
  className="squisq-doc-settings-btn squisq-doc-settings-btn--secondary"
271
297
  onClick={() => handleSave('library')}
272
- title="Save to your browser-local library so other docs can use it"
298
+ title={
299
+ importedLayouts && importedLayouts.length > 0
300
+ ? 'Save to your browser-local library so other docs can use it (imported slide layouts only save to the document)'
301
+ : 'Save to your browser-local library so other docs can use it'
302
+ }
273
303
  >
274
304
  Save to library
275
305
  </button>
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ImportThemeSection
3
+ *
4
+ * "Infer theme from a file" affordance shared by the CustomThemeDialog pane
5
+ * and the ThemeCustomizerPanel popover: a Choose-file button plus a local
6
+ * dropzone that accepts a Word/PowerPoint/Excel file, runs
7
+ * `inferThemeFromFile` from `@bendyline/squisq-formats/infer` (loaded
8
+ * lazily), and hands back a `Partial<Draft>` patch for the host's
9
+ * `updateDraft` — so the existing live-preview path applies unchanged.
10
+ *
11
+ * The dropzone handles its own drag events with `stopPropagation` so a file
12
+ * dropped here never reaches EditorShell's document-import drop handling.
13
+ */
14
+
15
+ import { useCallback, useRef, useState } from 'react';
16
+ import type { DragEvent } from 'react';
17
+ import type { CustomTemplateDefinition, Theme } from '@bendyline/squisq/schemas';
18
+ import { draftPatchFromImportedTheme, type Draft } from './themeDraft';
19
+
20
+ export interface ImportedThemeResult {
21
+ theme: Theme;
22
+ /** PPTX only, when `allowLayouts`: custom templates derived from slide layouts. */
23
+ layouts?: CustomTemplateDefinition[];
24
+ warnings: string[];
25
+ fileName: string;
26
+ }
27
+
28
+ export interface ImportThemeSectionProps {
29
+ /** Fired on successful inference with the draft patch + full result. */
30
+ onImported: (patch: Partial<Draft>, result: ImportedThemeResult) => void;
31
+ /** Also derive PPTX slide layouts as custom templates. Default false. */
32
+ allowLayouts?: boolean;
33
+ }
34
+
35
+ const ACCEPT = '.docx,.pptx,.xlsx';
36
+ const SUPPORTED_RE = /\.(docx|pptx|xlsx)$/i;
37
+ const MAX_VISIBLE_WARNINGS = 4;
38
+
39
+ /**
40
+ * Read a File's bytes. Prefers the modern `arrayBuffer()`; falls back to
41
+ * FileReader for environments (e.g. some jsdom builds) whose Blob lacks it —
42
+ * same posture as the formats registry's byte normalizer.
43
+ */
44
+ function readFileBytes(file: File): Promise<ArrayBuffer> {
45
+ if (typeof file.arrayBuffer === 'function') return file.arrayBuffer();
46
+ return new Promise<ArrayBuffer>((resolve, reject) => {
47
+ const reader = new FileReader();
48
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
49
+ reader.onerror = () => reject(reader.error);
50
+ reader.readAsArrayBuffer(file);
51
+ });
52
+ }
53
+
54
+ export function ImportThemeSection({ onImported, allowLayouts = false }: ImportThemeSectionProps) {
55
+ const inputRef = useRef<HTMLInputElement>(null);
56
+ const [busy, setBusy] = useState(false);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [status, setStatus] = useState<string | null>(null);
59
+ const [warnings, setWarnings] = useState<string[]>([]);
60
+ const [dragOver, setDragOver] = useState(false);
61
+
62
+ const handleFile = useCallback(
63
+ async (file: File) => {
64
+ if (!SUPPORTED_RE.test(file.name)) {
65
+ setError('Only .docx, .pptx, or .xlsx files carry a theme.');
66
+ return;
67
+ }
68
+ setBusy(true);
69
+ setError(null);
70
+ setStatus(null);
71
+ setWarnings([]);
72
+ try {
73
+ const buffer = await readFileBytes(file);
74
+ const { inferThemeFromFile } = await import('@bendyline/squisq-formats/infer');
75
+ const nameHint = file.name.replace(/\.[^.]+$/, '');
76
+ const result = await inferThemeFromFile(buffer, {
77
+ inferLayouts: allowLayouts,
78
+ nameHint,
79
+ });
80
+ const layouts = result.layouts ?? [];
81
+ onImported(draftPatchFromImportedTheme(result.theme), {
82
+ theme: result.theme,
83
+ ...(layouts.length > 0 ? { layouts } : {}),
84
+ warnings: result.warnings,
85
+ fileName: file.name,
86
+ });
87
+ setStatus(
88
+ layouts.length > 0
89
+ ? `Imported colors and fonts from ${file.name} — ${layouts.length} slide layout${
90
+ layouts.length === 1 ? '' : 's'
91
+ } will be saved with the document.`
92
+ : `Imported colors and fonts from ${file.name}.`,
93
+ );
94
+ setWarnings(result.warnings);
95
+ } catch (err: unknown) {
96
+ setError(err instanceof Error ? err.message : 'Could not read a theme from that file.');
97
+ } finally {
98
+ setBusy(false);
99
+ if (inputRef.current) inputRef.current.value = '';
100
+ }
101
+ },
102
+ [allowLayouts, onImported],
103
+ );
104
+
105
+ // Local drag handling only — never let the editor's global drop routing
106
+ // treat a theme-source file as a document import.
107
+ const onDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
108
+ e.preventDefault();
109
+ e.stopPropagation();
110
+ setDragOver(true);
111
+ }, []);
112
+ const onDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
113
+ e.preventDefault();
114
+ e.stopPropagation();
115
+ setDragOver(false);
116
+ }, []);
117
+ const onDrop = useCallback(
118
+ (e: DragEvent<HTMLDivElement>) => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ setDragOver(false);
122
+ const file = e.dataTransfer.files?.[0];
123
+ if (file) void handleFile(file);
124
+ },
125
+ [handleFile],
126
+ );
127
+
128
+ const visibleWarnings = warnings.slice(0, MAX_VISIBLE_WARNINGS);
129
+ const hiddenWarnings = warnings.length - visibleWarnings.length;
130
+
131
+ return (
132
+ <div className="squisq-theme-import">
133
+ <div
134
+ className={`squisq-theme-import-drop${dragOver ? ' squisq-theme-import-drop--active' : ''}${
135
+ busy ? ' squisq-theme-import-drop--busy' : ''
136
+ }`}
137
+ onDragOver={onDragOver}
138
+ onDragLeave={onDragLeave}
139
+ onDrop={onDrop}
140
+ >
141
+ <button
142
+ type="button"
143
+ className="squisq-theme-customizer-button"
144
+ onClick={() => inputRef.current?.click()}
145
+ disabled={busy}
146
+ >
147
+ {busy ? 'Reading…' : 'Choose file…'}
148
+ </button>
149
+ <span className="squisq-theme-import-hint">or drop a .docx / .pptx / .xlsx here</span>
150
+ <input
151
+ ref={inputRef}
152
+ type="file"
153
+ accept={ACCEPT}
154
+ style={{ display: 'none' }}
155
+ onChange={(e) => {
156
+ const file = e.target.files?.[0];
157
+ if (file) void handleFile(file);
158
+ }}
159
+ aria-label="Import theme from file"
160
+ />
161
+ </div>
162
+ {error && (
163
+ <div className="squisq-theme-import-error" role="alert">
164
+ {error}
165
+ </div>
166
+ )}
167
+ {status && <div className="squisq-theme-import-status">{status}</div>}
168
+ {visibleWarnings.length > 0 && (
169
+ <ul className="squisq-theme-import-warnings">
170
+ {visibleWarnings.map((w, i) => (
171
+ <li key={i}>{w}</li>
172
+ ))}
173
+ {hiddenWarnings > 0 && <li>…and {hiddenWarnings} more</li>}
174
+ </ul>
175
+ )}
176
+ </div>
177
+ );
178
+ }
@@ -11,6 +11,7 @@ import {
11
11
  saveLibraryTheme,
12
12
  deleteLibraryTheme,
13
13
  clearThemeLibrary,
14
+ THEME_LIBRARY_STORAGE_KEY,
14
15
  } from '../customThemeLibrary';
15
16
  import { compileTheme } from '@bendyline/squisq/schemas';
16
17
  import type { Theme } from '@bendyline/squisq/schemas';
@@ -48,4 +49,12 @@ describe('customThemeLibrary', () => {
48
49
  clearThemeLibrary();
49
50
  expect(listLibraryThemes()).toEqual([]);
50
51
  });
52
+
53
+ it('filters invalid stored entries instead of crashing sort', () => {
54
+ window.localStorage.setItem(
55
+ THEME_LIBRARY_STORAGE_KEY,
56
+ JSON.stringify({ version: 1, themes: [null, {}, theme('valid')] }),
57
+ );
58
+ expect(listLibraryThemes().map((entry) => entry.id)).toEqual(['valid']);
59
+ });
51
60
  });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * ImportThemeSection — the "infer theme from a file" affordance. Verifies the
3
+ * draft patch handed to hosts, error handling, drop-type gating, and that a
4
+ * designer save with imported layouts writes theme + selection + templates in
5
+ * a single frontmatter update.
6
+ */
7
+
8
+ // @vitest-environment jsdom
9
+
10
+ import { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
12
+ import type { CustomTemplateDefinition, Theme } from '@bendyline/squisq/schemas';
13
+ import { compileTheme } from '@bendyline/squisq/schemas';
14
+ import { ImportThemeSection } from '../ImportThemeSection';
15
+ import { draftPatchFromImportedTheme } from '../themeDraft';
16
+ import { EditorProvider, useEditorContext } from '../../EditorContext';
17
+ import { PreviewSettingsProvider, usePreviewSettings } from '../../PreviewControls';
18
+
19
+ const { inferMock } = vi.hoisted(() => ({ inferMock: vi.fn() }));
20
+ vi.mock('@bendyline/squisq-formats/infer', () => ({ inferThemeFromFile: inferMock }));
21
+
22
+ afterEach(() => {
23
+ cleanup();
24
+ inferMock.mockReset();
25
+ });
26
+
27
+ const importedTheme: Theme = compileTheme({
28
+ id: 'custom-deck',
29
+ name: 'Deck',
30
+ seedColors: { primary: '#4472c4', background: '#fdfdf8', text: '#1a1a2e' },
31
+ typography: {
32
+ titleFont: { stackId: 'playfair' },
33
+ bodyFont: { custom: { name: 'Aptos', fallback: 'sans-serif' } },
34
+ },
35
+ colorSchemes: { accent1: { bg: '#101020', text: '#e2e8f0', accent: '#4472c4' } },
36
+ });
37
+
38
+ const importedLayout: CustomTemplateDefinition = {
39
+ name: 'pptx-two-content',
40
+ label: 'Two Content',
41
+ viewport: { width: 1920, height: 1080 },
42
+ layers: [
43
+ {
44
+ id: 'title',
45
+ type: 'text',
46
+ position: { x: '5%', y: '10%', width: '90%', height: '20%' },
47
+ content: { text: '{title}', style: { fontSize: 88, color: '#111111' } },
48
+ },
49
+ ],
50
+ };
51
+
52
+ function pickFile(name = 'deck.pptx'): void {
53
+ const file = new File([new Uint8Array([0x50, 0x4b, 3, 4])], name);
54
+ fireEvent.change(screen.getByLabelText('Import theme from file'), {
55
+ target: { files: [file] },
56
+ });
57
+ }
58
+
59
+ describe('ImportThemeSection', () => {
60
+ it('imports a theme and hands the host a draft patch + layouts', async () => {
61
+ inferMock.mockResolvedValue({
62
+ theme: importedTheme,
63
+ extraction: { sourceFormat: 'pptx', colorMap: { bg1: 'lt1', tx1: 'dk1' }, warnings: [] },
64
+ layouts: [importedLayout],
65
+ warnings: ['theme: accent4 color could not be resolved; dropped'],
66
+ });
67
+ const onImported = vi.fn();
68
+ render(<ImportThemeSection allowLayouts onImported={onImported} />);
69
+
70
+ pickFile();
71
+ await waitFor(() => expect(onImported).toHaveBeenCalledTimes(1));
72
+
73
+ expect(inferMock).toHaveBeenCalledWith(expect.anything(), {
74
+ inferLayouts: true,
75
+ nameHint: 'deck',
76
+ });
77
+
78
+ const [patch, result] = onImported.mock.calls[0]!;
79
+ expect(patch.name).toBe('Deck');
80
+ expect(patch.seeds.primary).toBe('#4472c4');
81
+ expect(patch.accentsEdited).toBe(true);
82
+ expect(patch.accents).toEqual([{ key: 'accent1', color: '#4472c4' }]);
83
+ expect(patch.titleFont).toEqual({ kind: 'curated', stackId: 'playfair' });
84
+ expect(patch.bodyFont).toEqual({
85
+ kind: 'custom',
86
+ customName: 'Aptos',
87
+ customFallback: 'sans-serif',
88
+ });
89
+ // The patch never touches base or style presets.
90
+ expect('baseId' in patch).toBe(false);
91
+ expect('borderRadius' in patch).toBe(false);
92
+
93
+ expect(result.layouts).toHaveLength(1);
94
+ expect(result.fileName).toBe('deck.pptx');
95
+ expect(screen.getByText(/1 slide layout/)).toBeTruthy();
96
+ expect(screen.getByText(/accent4/)).toBeTruthy();
97
+ });
98
+
99
+ it('shows the inference error and never fires onImported', async () => {
100
+ inferMock.mockRejectedValue(new Error('No theme part found in this file.'));
101
+ const onImported = vi.fn();
102
+ render(<ImportThemeSection onImported={onImported} />);
103
+
104
+ pickFile();
105
+ await waitFor(() =>
106
+ expect(screen.getByRole('alert').textContent).toContain('No theme part found'),
107
+ );
108
+ expect(onImported).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('rejects unsupported file types dropped on the zone', async () => {
112
+ const onImported = vi.fn();
113
+ const { container } = render(<ImportThemeSection onImported={onImported} />);
114
+ const zone = container.querySelector('.squisq-theme-import-drop')!;
115
+ fireEvent.drop(zone, {
116
+ dataTransfer: { files: [new File(['x'], 'notes.txt')] },
117
+ });
118
+ await waitFor(() =>
119
+ expect(screen.getByRole('alert').textContent).toContain('Only .docx, .pptx, or .xlsx'),
120
+ );
121
+ expect(inferMock).not.toHaveBeenCalled();
122
+ expect(onImported).not.toHaveBeenCalled();
123
+ });
124
+ });
125
+
126
+ describe('draftPatchFromImportedTheme', () => {
127
+ it('carries seeds/fonts/accents and nothing else', () => {
128
+ const patch = draftPatchFromImportedTheme(importedTheme);
129
+ expect(Object.keys(patch).sort()).toEqual([
130
+ 'accents',
131
+ 'accentsEdited',
132
+ 'bodyFont',
133
+ 'name',
134
+ 'seeds',
135
+ 'titleFont',
136
+ ]);
137
+ expect(patch.seeds).toMatchObject({ primary: '#4472c4', background: '#fdfdf8' });
138
+ });
139
+ });
140
+
141
+ // ── Designer save with imported layouts → single frontmatter write ───
142
+
143
+ function DesignerSaveHarness() {
144
+ const settings = usePreviewSettings();
145
+ const { markdownSource } = useEditorContext();
146
+ return (
147
+ <div>
148
+ <button type="button" onClick={() => settings.openThemeDesigner(null)}>
149
+ open designer
150
+ </button>
151
+ <button
152
+ type="button"
153
+ onClick={() =>
154
+ settings.themeDesigner?.onSave(importedTheme, 'doc', { templates: [importedLayout] })
155
+ }
156
+ >
157
+ save with layouts
158
+ </button>
159
+ <pre data-testid="markdown-source">{markdownSource}</pre>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function HarnessRoot() {
165
+ const { doc } = useEditorContext();
166
+ return (
167
+ <PreviewSettingsProvider doc={doc}>
168
+ <DesignerSaveHarness />
169
+ </PreviewSettingsProvider>
170
+ );
171
+ }
172
+
173
+ describe('theme designer save with imported layouts', () => {
174
+ it('writes theme payload, selection, and templates in one frontmatter update', async () => {
175
+ render(
176
+ <EditorProvider initialMarkdown={'# Hello\n\nBody.\n'}>
177
+ <HarnessRoot />
178
+ </EditorProvider>,
179
+ );
180
+
181
+ fireEvent.click(screen.getByText('open designer'));
182
+ fireEvent.click(screen.getByText('save with layouts'));
183
+
184
+ await waitFor(() => {
185
+ const source = screen.getByTestId('markdown-source').textContent ?? '';
186
+ expect(source).toContain('squisq-theme: custom-deck');
187
+ expect(source).toContain('squisq-custom-themes');
188
+ expect(source).toContain('squisq-custom-templates');
189
+ expect(source).toContain('pptx-two-content');
190
+ });
191
+ });
192
+ });
@@ -14,7 +14,7 @@
14
14
  * unavailable.
15
15
  */
16
16
 
17
- import type { Theme } from '@bendyline/squisq/schemas';
17
+ import { validateTheme, type Theme } from '@bendyline/squisq/schemas';
18
18
 
19
19
  export const THEME_LIBRARY_STORAGE_KEY = 'squisq:custom-theme-library';
20
20
 
@@ -38,12 +38,15 @@ function safeStorage(): Storage | null {
38
38
  function readPayload(): LibraryPayload {
39
39
  const storage = safeStorage();
40
40
  if (!storage) return { version: 1, themes: [] };
41
- const raw = storage.getItem(THEME_LIBRARY_STORAGE_KEY);
42
- if (!raw) return { version: 1, themes: [] };
43
41
  try {
42
+ const raw = storage.getItem(THEME_LIBRARY_STORAGE_KEY);
43
+ if (!raw) return { version: 1, themes: [] };
44
44
  const parsed = JSON.parse(raw) as Partial<LibraryPayload>;
45
45
  if (parsed && Array.isArray(parsed.themes)) {
46
- return { version: 1, themes: parsed.themes };
46
+ return {
47
+ version: 1,
48
+ themes: parsed.themes.filter((theme): theme is Theme => validateTheme(theme).valid),
49
+ };
47
50
  }
48
51
  } catch {
49
52
  // Corrupt payload — drop it.
@@ -27,5 +27,11 @@ export {
27
27
  CustomThemeDialog,
28
28
  type CustomThemeDialogProps,
29
29
  type ThemeSaveTarget,
30
+ type ThemeSaveExtras,
30
31
  } from './CustomThemeDialog';
31
- export { compileDraft, themeToDraft, type Draft } from './themeDraft';
32
+ export {
33
+ ImportThemeSection,
34
+ type ImportThemeSectionProps,
35
+ type ImportedThemeResult,
36
+ } from './ImportThemeSection';
37
+ export { compileDraft, themeToDraft, draftPatchFromImportedTheme, type Draft } from './themeDraft';
@@ -15,7 +15,7 @@ import type {
15
15
  ThemeSeedColors,
16
16
  ThemeColorScheme,
17
17
  } from '@bendyline/squisq/schemas';
18
- import { compileTheme, deriveScale, isHex } from '@bendyline/squisq/schemas';
18
+ import { accentToColorScheme, compileTheme } from '@bendyline/squisq/schemas';
19
19
 
20
20
  // ── Preset → schema-value tables ────────────────────────────────────
21
21
 
@@ -164,14 +164,12 @@ export function accentsFromTheme(theme: Theme | null): AccentInput[] {
164
164
  }
165
165
 
166
166
  /**
167
- * Expand one accent color into a full `{bg, text, accent}` color scheme via
168
- * the OKLCh scale dark saturated bg, light readable text, the picked accent.
169
- * Falls back to a neutral scheme when the input isn't a valid hex.
167
+ * Expand one accent color into a full `{bg, text, accent}` color scheme.
168
+ * Delegates to core's `accentToColorScheme` so file-import theme inference
169
+ * and the editor's accent list derive identical schemes.
170
170
  */
171
171
  export function schemeFromAccent(accent: string): ThemeColorScheme {
172
- if (!isHex(accent)) return { bg: '#1a202c', text: '#e2e8f0', accent: '#63b3ed' };
173
- const scale = deriveScale(accent, 0.3);
174
- return { bg: scale.darker2, text: scale.lighter2, accent: scale.base };
172
+ return accentToColorScheme(accent);
175
173
  }
176
174
 
177
175
  export function themeToDraft(theme: Theme | null): Draft {
@@ -220,6 +218,24 @@ export function slugify(s: string): string {
220
218
  );
221
219
  }
222
220
 
221
+ /**
222
+ * Draft patch from a theme inferred from an imported office file: name,
223
+ * seeds, accents (marked authored so the file's schemes survive a re-save),
224
+ * and fonts. Deliberately leaves `baseId` and the style presets untouched —
225
+ * the user's in-progress choices there aren't the file's to overwrite.
226
+ */
227
+ export function draftPatchFromImportedTheme(theme: Theme): Partial<Draft> {
228
+ const d = themeToDraft(theme);
229
+ return {
230
+ name: d.name,
231
+ seeds: d.seeds,
232
+ accents: d.accents,
233
+ accentsEdited: d.accents.length > 0,
234
+ titleFont: d.titleFont,
235
+ bodyFont: d.bodyFont,
236
+ };
237
+ }
238
+
223
239
  export interface CompileDraftOptions {
224
240
  /** Existing id to preserve across edits (keeps `squisq-theme` selection stable). */
225
241
  existingId?: string;
@@ -22,8 +22,9 @@ import {
22
22
  type SceneCommand,
23
23
  } from '../scene';
24
24
  import type { SceneTextEditConfig } from '../scene/text/sceneTextConfig';
25
+ import type { SceneTextChannel } from '../scene/text/sceneTextChannel';
25
26
  import { markdownToTiptap } from '../tiptapBridge';
26
- import type { DiagramRFNode, DiagramRFEdge } from './useDiagramData';
27
+ import type { DiagramNode, DiagramEdge } from './types';
27
28
  import { DIAGRAM_VIEWPORT, DIAGRAM_TOOLS } from './diagramConstants';
28
29
 
29
30
  export type DiagramCommand =
@@ -36,8 +37,8 @@ export type DiagramCommand =
36
37
  | { kind: 'removeNode'; nodeId: string };
37
38
 
38
39
  interface DiagramCanvasProps {
39
- nodes: DiagramRFNode[];
40
- edges: DiagramRFEdge[];
40
+ nodes: DiagramNode[];
41
+ edges: DiagramEdge[];
41
42
  onCommand: (cmd: DiagramCommand) => void;
42
43
  /** When true, render the maximize button. Click toggles `onToggleMaximize`. */
43
44
  showMaximize?: boolean;
@@ -54,6 +55,8 @@ interface DiagramCanvasProps {
54
55
  onActiveToolIdChange?: (id: string) => void;
55
56
  /** Forwarded to the Scene so the host can drive a Delete action. */
56
57
  onSelectionChange?: (ids: ReadonlySet<string>) => void;
58
+ /** Per-editor toolbar bridge for detached scene roots. */
59
+ textChannel?: SceneTextChannel;
57
60
  }
58
61
 
59
62
  const TOOLS = DIAGRAM_TOOLS;
@@ -68,6 +71,7 @@ export function DiagramCanvas({
68
71
  activeToolId: controlledToolId,
69
72
  onActiveToolIdChange,
70
73
  onSelectionChange,
74
+ textChannel,
71
75
  }: DiagramCanvasProps) {
72
76
  const scene = useMemo(
73
77
  () => buildDiagramScene(incomingNodes, incomingEdges),
@@ -171,6 +175,7 @@ export function DiagramCanvas({
171
175
  incomingNodesRef.current = incomingNodes;
172
176
  const textConfig = useMemo<SceneTextEditConfig>(
173
177
  () => ({
178
+ channel: textChannel,
174
179
  resolveEditableId: (id) => (nodeIdFromCardLayerId(id) ? id : null),
175
180
  getHtml: (id) => {
176
181
  const nodeId = nodeIdFromCardLayerId(id);
@@ -184,7 +189,7 @@ export function DiagramCanvas({
184
189
  },
185
190
  level: 'inline',
186
191
  }),
187
- [handleSceneCommand],
192
+ [handleSceneCommand, textChannel],
188
193
  );
189
194
 
190
195
  return (
@@ -0,0 +1,35 @@
1
+ /** Data shapes consumed by `DiagramCanvas` / `buildDiagramScene`. */
2
+
3
+ import type { DiagramEdgeAnchor } from '@bendyline/squisq/schemas';
4
+ import type { ConnectorRouting } from '@bendyline/squisq/doc';
5
+
6
+ export interface DiagramNode {
7
+ id: string;
8
+ position: { x: number; y: number };
9
+ data: { label: string };
10
+ type?: string;
11
+ /** Per-node width override in canvas units. */
12
+ width?: number;
13
+ /** Per-node height override in canvas units. */
14
+ height?: number;
15
+ /** Render as a background container card (top-anchored label, muted fill). */
16
+ kind?: 'container';
17
+ }
18
+
19
+ export interface DiagramEdge {
20
+ id: string;
21
+ source: string;
22
+ target: string;
23
+ label?: string;
24
+ /** False → no end arrowhead. Undefined keeps the diagram default (arrow). */
25
+ directed?: boolean;
26
+ sourceAnchor?: DiagramEdgeAnchor;
27
+ targetAnchor?: DiagramEdgeAnchor;
28
+ routing?: ConnectorRouting;
29
+ }
30
+
31
+ export interface DiagramData {
32
+ nodes: DiagramNode[];
33
+ edges: DiagramEdge[];
34
+ warnings: string[];
35
+ }