@bendyline/squisq-editor-react 1.6.2 → 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/README.md +12 -3
- package/dist/index.d.ts +1275 -183
- package/dist/index.js +20345 -12777
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +1607 -241
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +39 -33
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +349 -261
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +107 -14
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +362 -89
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/RecorderEntry.tsx +2 -0
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +12 -3
- package/src/TemplatePicker.tsx +75 -21
- package/src/ThemeCustomizerPanel.tsx +8 -0
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +357 -51
- package/src/TransitionPicker.tsx +36 -4
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/WysiwygEditor.tsx +124 -91
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +123 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +0 -12
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +15 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +382 -5
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +71 -7
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
- package/src/asciiDiagram/asciiDiagramData.ts +149 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/codeContext/types.ts +1 -1
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +25 -6
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/frontmatterSettings.ts +23 -0
- package/src/headingTransition.ts +0 -15
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +198 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +48 -18
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +5 -1
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +17 -3
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +21 -7
- package/src/scene/layers/edgeGeometry.ts +9 -1
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +5 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +13 -4
- package/src/scene/tools/SceneTool.ts +8 -0
- package/src/scene/tools/SelectTool.ts +39 -23
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +596 -0
- package/src/styles/editor.css +736 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +189 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +12 -5
- package/src/timeline/TimelineEditorWidget.tsx +861 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
- package/src/timeline/__tests__/timelineOps.test.ts +277 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +421 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +381 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +47 -21
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +390 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +177 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
- package/src/treeview/treeOps.ts +231 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +99 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -272
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll-position handoff between the WYSIWYG and source editors.
|
|
3
|
+
*
|
|
4
|
+
* The two surfaces render the same document at very different heights, so a
|
|
5
|
+
* normalized position is a better shared currency than raw pixels. Keeping
|
|
6
|
+
* these adapters separate from EditorContext also makes the DOM/Monaco math
|
|
7
|
+
* independently testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface MonacoScrollEditor {
|
|
11
|
+
getScrollTop(): number;
|
|
12
|
+
getScrollHeight(): number;
|
|
13
|
+
getLayoutInfo(): { height: number };
|
|
14
|
+
setScrollTop(scrollTop: number): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface WysiwygScrollEditor {
|
|
18
|
+
view: { dom: Element };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clampRatio(value: number): number {
|
|
22
|
+
if (!Number.isFinite(value)) return 0;
|
|
23
|
+
return Math.min(1, Math.max(0, value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function scrollRatio(
|
|
27
|
+
scrollTop: number,
|
|
28
|
+
scrollHeight: number,
|
|
29
|
+
viewportHeight: number,
|
|
30
|
+
): number {
|
|
31
|
+
const availableScroll = scrollHeight - viewportHeight;
|
|
32
|
+
if (availableScroll <= 0) return 0;
|
|
33
|
+
return clampRatio(scrollTop / availableScroll);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scrollTopForRatio(
|
|
37
|
+
ratio: number,
|
|
38
|
+
scrollHeight: number,
|
|
39
|
+
viewportHeight: number,
|
|
40
|
+
): number {
|
|
41
|
+
return clampRatio(ratio) * Math.max(0, scrollHeight - viewportHeight);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readMonacoScrollRatio(editor: MonacoScrollEditor): number {
|
|
45
|
+
return scrollRatio(
|
|
46
|
+
editor.getScrollTop(),
|
|
47
|
+
editor.getScrollHeight(),
|
|
48
|
+
editor.getLayoutInfo().height,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function restoreMonacoScrollRatio(editor: MonacoScrollEditor, ratio: number): void {
|
|
53
|
+
editor.setScrollTop(
|
|
54
|
+
scrollTopForRatio(ratio, editor.getScrollHeight(), editor.getLayoutInfo().height),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findWysiwygScrollElement(editor: WysiwygScrollEditor): HTMLElement | null {
|
|
59
|
+
return editor.view.dom.closest<HTMLElement>('.squisq-wysiwyg-container');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readWysiwygScrollRatio(editor: WysiwygScrollEditor): number | null {
|
|
63
|
+
const element = findWysiwygScrollElement(editor);
|
|
64
|
+
if (!element) return null;
|
|
65
|
+
return scrollRatio(element.scrollTop, element.scrollHeight, element.clientHeight);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function restoreWysiwygScrollRatio(editor: WysiwygScrollEditor, ratio: number): boolean {
|
|
69
|
+
const element = findWysiwygScrollElement(editor);
|
|
70
|
+
if (!element) return false;
|
|
71
|
+
element.scrollTop = scrollTopForRatio(ratio, element.scrollHeight, element.clientHeight);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
package/src/emojiData.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* lookup; the user can paste anything we don't have.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
interface EmojiEntry {
|
|
17
17
|
/** The actual emoji glyph (may be multi-codepoint, e.g. ZWJ sequences). */
|
|
18
18
|
char: string;
|
|
19
19
|
/** Display name shown in the tooltip. */
|
|
@@ -22,7 +22,7 @@ export interface EmojiEntry {
|
|
|
22
22
|
keywords: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
interface EmojiCategory {
|
|
26
26
|
id: string;
|
|
27
27
|
label: string;
|
|
28
28
|
/** Single-glyph icon shown on the tab. */
|
|
@@ -34,7 +34,7 @@ function e(char: string, name: string, ...kw: string[]): EmojiEntry {
|
|
|
34
34
|
return { char, name, keywords: [name, ...kw].join(' ').toLowerCase() };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const EMOJI_CATEGORIES: EmojiCategory[] = [
|
|
38
38
|
{
|
|
39
39
|
id: 'smileys',
|
|
40
40
|
label: 'Smileys',
|
|
@@ -1216,26 +1216,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [
|
|
|
1216
1216
|
},
|
|
1217
1217
|
];
|
|
1218
1218
|
|
|
1219
|
-
/**
|
|
1220
|
-
* Flat list of every emoji across all categories — used as the search
|
|
1221
|
-
* corpus and to expose a `getEmojiByChar` lookup.
|
|
1222
|
-
*/
|
|
1223
|
-
export const ALL_EMOJIS: EmojiEntry[] = EMOJI_CATEGORIES.flatMap((c) => c.emojis);
|
|
1224
|
-
|
|
1225
|
-
/** Search for emoji whose name or keywords contain the query (case-insensitive). */
|
|
1226
|
-
export function searchEmojis(query: string, limit = 80): EmojiEntry[] {
|
|
1227
|
-
const q = query.trim().toLowerCase();
|
|
1228
|
-
if (!q) return [];
|
|
1229
|
-
const out: EmojiEntry[] = [];
|
|
1230
|
-
for (const entry of ALL_EMOJIS) {
|
|
1231
|
-
if (entry.keywords.includes(q)) {
|
|
1232
|
-
out.push(entry);
|
|
1233
|
-
if (out.length >= limit) break;
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
return out;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
1219
|
// ── Unified picker entries (emoji + FontAwesome icons) ─────────────
|
|
1240
1220
|
|
|
1241
1221
|
import { ICONS } from '@bendyline/squisq/icons';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Canonical and legacy keys for frontmatter settings managed by the editor. */
|
|
2
|
+
export const FRONTMATTER_SETTING_KEYS = {
|
|
3
|
+
theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
|
|
4
|
+
transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
|
|
5
|
+
captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
|
|
6
|
+
coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** Runtime defaults whose equivalent frontmatter entries can be omitted. */
|
|
10
|
+
export const FRONTMATTER_SETTING_DEFAULTS = {
|
|
11
|
+
theme: 'standard',
|
|
12
|
+
transform: '',
|
|
13
|
+
captions: 'standard',
|
|
14
|
+
coverSlide: true,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** Return `null` when a setting matches its runtime default so writers remove it. */
|
|
18
|
+
export function omitFrontmatterDefault<T extends string | number | boolean>(
|
|
19
|
+
value: T,
|
|
20
|
+
defaultValue: T,
|
|
21
|
+
): T | null {
|
|
22
|
+
return value === defaultValue ? null : value;
|
|
23
|
+
}
|
package/src/headingTransition.ts
CHANGED
|
@@ -276,21 +276,6 @@ export function setHeadingAttrsTransition(
|
|
|
276
276
|
};
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
/**
|
|
280
|
-
* Legacy single-channel writer for callers that only own `dataBlockAttrs`.
|
|
281
|
-
* Internal editor surfaces should use {@link setHeadingAttrsTransition} so new
|
|
282
|
-
* transition edits land in the squisq-native `{[…]}` params.
|
|
283
|
-
*/
|
|
284
|
-
export function setBlockAttrsTransition(
|
|
285
|
-
blockAttrsInner: string | null | undefined,
|
|
286
|
-
next: TransitionFields,
|
|
287
|
-
): string | null {
|
|
288
|
-
const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
|
|
289
|
-
attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
|
|
290
|
-
const pandoc = pandocBlockOrNull(attrs);
|
|
291
|
-
return pandoc ? pandoc.slice(1, -1) : null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
279
|
// ============================================
|
|
295
280
|
// shared
|
|
296
281
|
// ============================================
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* the canvas is the viewport and `blockTime` is 0 (no animation).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
18
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
19
19
|
import { PathLayer } from '@bendyline/squisq-react';
|
|
20
20
|
import type { ImageEditDoc, ImageEditLayer } from '@bendyline/squisq/schemas';
|
|
21
21
|
import type { CanvasRect, ImageEditorAction, ImageEditorTool } from './state.js';
|
|
@@ -97,6 +97,7 @@ export function CanvasSurface({
|
|
|
97
97
|
surfaceRef,
|
|
98
98
|
requestEditLayerId,
|
|
99
99
|
}: CanvasSurfaceProps) {
|
|
100
|
+
const checkerId = `squisq-image-editor-checker-${useId().replace(/:/g, '')}`;
|
|
100
101
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
101
102
|
const dragRef = useRef<DragState | null>(null);
|
|
102
103
|
const [, forceRender] = useState(0);
|
|
@@ -402,16 +403,11 @@ export function CanvasSurface({
|
|
|
402
403
|
fill={
|
|
403
404
|
doc.canvas.background && doc.canvas.background !== 'transparent'
|
|
404
405
|
? doc.canvas.background
|
|
405
|
-
:
|
|
406
|
+
: `url(#${checkerId})`
|
|
406
407
|
}
|
|
407
408
|
/>
|
|
408
409
|
<defs>
|
|
409
|
-
<pattern
|
|
410
|
-
id="squisq-image-editor-checker"
|
|
411
|
-
width="16"
|
|
412
|
-
height="16"
|
|
413
|
-
patternUnits="userSpaceOnUse"
|
|
414
|
-
>
|
|
410
|
+
<pattern id={checkerId} width="16" height="16" patternUnits="userSpaceOnUse">
|
|
415
411
|
<rect width="16" height="16" fill="#f0f0f0" />
|
|
416
412
|
<rect width="8" height="8" fill="#d0d0d0" />
|
|
417
413
|
<rect x="8" y="8" width="8" height="8" fill="#d0d0d0" />
|
|
@@ -461,9 +457,16 @@ export function CanvasSurface({
|
|
|
461
457
|
})}
|
|
462
458
|
|
|
463
459
|
{/* Selection handles */}
|
|
464
|
-
{selectedLayer &&
|
|
465
|
-
|
|
466
|
-
|
|
460
|
+
{selectedLayer &&
|
|
461
|
+
paddedSelectionBox &&
|
|
462
|
+
tool === 'select' &&
|
|
463
|
+
!selectedLayer.locked &&
|
|
464
|
+
editingLayerId !== selectedLayer.id && (
|
|
465
|
+
<SelectionHandles
|
|
466
|
+
box={paddedSelectionBox}
|
|
467
|
+
onHandlePointerDown={onPointerDownHandle}
|
|
468
|
+
/>
|
|
469
|
+
)}
|
|
467
470
|
|
|
468
471
|
{/* Inline text editor — foreignObject overlays the hidden text layer */}
|
|
469
472
|
{editingLayerId &&
|
|
@@ -471,16 +474,16 @@ export function CanvasSurface({
|
|
|
471
474
|
const editLayer = doc.layers.find((l) => l.id === editingLayerId);
|
|
472
475
|
if (!editLayer || editLayer.type !== 'text') return null;
|
|
473
476
|
const pos = layerBox(editLayer, doc);
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
+
const textLayer = editLayer as ImageEditLayer & { type: 'text' };
|
|
478
|
+
const s = textLayer.content;
|
|
479
|
+
const editBox = measureTextLayerBox(textLayer, pos);
|
|
477
480
|
return (
|
|
478
481
|
<foreignObject
|
|
479
482
|
key={`inline-edit-${editingLayerId}`}
|
|
480
|
-
x={
|
|
481
|
-
y={
|
|
482
|
-
width={
|
|
483
|
-
height={
|
|
483
|
+
x={editBox.x}
|
|
484
|
+
y={editBox.y}
|
|
485
|
+
width={editBox.width}
|
|
486
|
+
height={editBox.height}
|
|
484
487
|
style={{ overflow: 'visible' }}
|
|
485
488
|
>
|
|
486
489
|
<textarea
|
|
@@ -503,9 +506,11 @@ export function CanvasSurface({
|
|
|
503
506
|
}}
|
|
504
507
|
onPointerDown={(e) => e.stopPropagation()}
|
|
505
508
|
onClick={(e) => e.stopPropagation()}
|
|
509
|
+
wrap="off"
|
|
506
510
|
style={{
|
|
507
511
|
display: 'block',
|
|
508
512
|
width: '100%',
|
|
513
|
+
height: '100%',
|
|
509
514
|
padding: 0,
|
|
510
515
|
margin: 0,
|
|
511
516
|
border: 'none',
|
|
@@ -514,9 +519,9 @@ export function CanvasSurface({
|
|
|
514
519
|
resize: 'none',
|
|
515
520
|
background: 'transparent',
|
|
516
521
|
fontFamily: s.style.fontFamily ?? 'sans-serif',
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
fontSize: `${s.style.fontSize
|
|
522
|
+
// The foreignObject participates in the SVG viewBox transform, so its
|
|
523
|
+
// contents use canvas units and inherit zoom from the SVG exactly once.
|
|
524
|
+
fontSize: `${s.style.fontSize}px`,
|
|
520
525
|
fontWeight: s.style.fontWeight ?? 'normal',
|
|
521
526
|
color: s.style.color,
|
|
522
527
|
textAlign: (s.style.textAlign ?? 'left') as 'left' | 'center' | 'right',
|
|
@@ -677,8 +682,9 @@ function measureTextLayerBox(
|
|
|
677
682
|
}
|
|
678
683
|
const lineHeight = style.lineHeight ?? 1.4;
|
|
679
684
|
const lineHeightPx = fontSize * lineHeight;
|
|
680
|
-
//
|
|
681
|
-
|
|
685
|
+
// Use full line boxes so the SVG selection and textarea editor share
|
|
686
|
+
// identical bounds without clipping the caret or the final line.
|
|
687
|
+
const totalHeight = Math.max(1, lines.length) * lineHeightPx;
|
|
682
688
|
// Mirror the textAnchor logic in EditorTextLayer.
|
|
683
689
|
const anchor =
|
|
684
690
|
style.textAlign === 'center' ? 'middle' : style.textAlign === 'right' ? 'end' : 'start';
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* panel shows the array reversed so "top" appears at the top of the list.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
8
|
import type { ImageEditDoc } from '@bendyline/squisq/schemas';
|
|
8
9
|
import type { ImageEditorAction } from './state.js';
|
|
9
10
|
import {
|
|
@@ -12,23 +13,95 @@ import {
|
|
|
12
13
|
CloseIcon,
|
|
13
14
|
EyeIcon,
|
|
14
15
|
EyeOffIcon,
|
|
16
|
+
ImageIcon,
|
|
15
17
|
LockIcon,
|
|
18
|
+
PlusIcon,
|
|
19
|
+
ShapeIcon,
|
|
20
|
+
TextIcon,
|
|
16
21
|
UnlockIcon,
|
|
17
22
|
} from './icons.js';
|
|
18
23
|
|
|
24
|
+
export type AddableLayerKind = 'text' | 'shape' | 'image';
|
|
25
|
+
|
|
19
26
|
export interface LayersPanelProps {
|
|
20
27
|
doc: ImageEditDoc;
|
|
21
28
|
selectedLayerId: string | null;
|
|
22
29
|
dispatch: (action: ImageEditorAction) => void;
|
|
30
|
+
onAddLayer: (kind: AddableLayerKind) => void;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
const ADD_LAYER_OPTIONS: ReadonlyArray<{
|
|
34
|
+
kind: AddableLayerKind;
|
|
35
|
+
label: string;
|
|
36
|
+
icon: React.ReactNode;
|
|
37
|
+
}> = [
|
|
38
|
+
{ kind: 'text', label: 'Text layer', icon: <TextIcon /> },
|
|
39
|
+
{ kind: 'shape', label: 'Shape layer', icon: <ShapeIcon /> },
|
|
40
|
+
{ kind: 'image', label: 'Image layer…', icon: <ImageIcon /> },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function LayersPanel({ doc, selectedLayerId, dispatch, onAddLayer }: LayersPanelProps) {
|
|
44
|
+
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
|
45
|
+
const addMenuRef = useRef<HTMLSpanElement | null>(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!addMenuOpen) return;
|
|
49
|
+
|
|
50
|
+
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
51
|
+
if (!addMenuRef.current?.contains(event.target as Node)) setAddMenuOpen(false);
|
|
52
|
+
};
|
|
53
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
54
|
+
if (event.key === 'Escape') setAddMenuOpen(false);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
document.addEventListener('mousedown', closeOnOutsideClick);
|
|
58
|
+
document.addEventListener('keydown', closeOnEscape);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener('mousedown', closeOnOutsideClick);
|
|
61
|
+
document.removeEventListener('keydown', closeOnEscape);
|
|
62
|
+
};
|
|
63
|
+
}, [addMenuOpen]);
|
|
64
|
+
|
|
26
65
|
// Visual order: top of stack first.
|
|
27
66
|
const ordered = doc.layers.slice().reverse();
|
|
28
67
|
|
|
29
68
|
return (
|
|
30
69
|
<div className="squisq-image-editor-layers" data-testid="image-editor-layers">
|
|
31
|
-
<div className="squisq-image-editor-panel-header">
|
|
70
|
+
<div className="squisq-image-editor-panel-header squisq-image-editor-layers-header">
|
|
71
|
+
<span>Layers</span>
|
|
72
|
+
<span ref={addMenuRef} className="squisq-image-editor-layer-add">
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="squisq-image-editor-layer-add-button"
|
|
76
|
+
onClick={() => setAddMenuOpen((open) => !open)}
|
|
77
|
+
aria-label="Add layer"
|
|
78
|
+
title="Add layer"
|
|
79
|
+
aria-haspopup="menu"
|
|
80
|
+
aria-expanded={addMenuOpen}
|
|
81
|
+
>
|
|
82
|
+
<PlusIcon />
|
|
83
|
+
</button>
|
|
84
|
+
{addMenuOpen && (
|
|
85
|
+
<div className="squisq-image-editor-layer-add-menu" role="menu">
|
|
86
|
+
{ADD_LAYER_OPTIONS.map(({ kind, label, icon }) => (
|
|
87
|
+
<button
|
|
88
|
+
key={kind}
|
|
89
|
+
type="button"
|
|
90
|
+
className="squisq-image-editor-layer-add-menu-item"
|
|
91
|
+
role="menuitem"
|
|
92
|
+
onClick={() => {
|
|
93
|
+
setAddMenuOpen(false);
|
|
94
|
+
onAddLayer(kind);
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{icon}
|
|
98
|
+
<span>{label}</span>
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
32
105
|
<ul className="squisq-image-editor-layer-list">
|
|
33
106
|
{ordered.length === 0 && <li className="squisq-image-editor-layer-empty">No layers yet</li>}
|
|
34
107
|
{ordered.map((layer) => {
|
|
@@ -38,6 +111,7 @@ export function LayersPanel({ doc, selectedLayerId, dispatch }: LayersPanelProps
|
|
|
38
111
|
const canMoveUp = stackIndex < doc.layers.length - 1;
|
|
39
112
|
const canMoveDown = stackIndex > 0;
|
|
40
113
|
const isSelected = selectedLayerId === layer.id;
|
|
114
|
+
const layerName = layer.name ?? defaultLayerName(layer);
|
|
41
115
|
|
|
42
116
|
return (
|
|
43
117
|
<li
|
|
@@ -84,8 +158,9 @@ export function LayersPanel({ doc, selectedLayerId, dispatch }: LayersPanelProps
|
|
|
84
158
|
type="button"
|
|
85
159
|
className="squisq-image-editor-layer-name"
|
|
86
160
|
onClick={() => dispatch({ type: 'select', layerId: layer.id })}
|
|
161
|
+
title={layerName}
|
|
87
162
|
>
|
|
88
|
-
|
|
163
|
+
<span className="squisq-image-editor-layer-name-text">{layerName}</span>
|
|
89
164
|
<span className="squisq-image-editor-layer-kind">{layer.type}</span>
|
|
90
165
|
</button>
|
|
91
166
|
<button
|
|
@@ -18,6 +18,8 @@ export interface ToolbarProps {
|
|
|
18
18
|
dispatch: (a: ImageEditorAction) => void;
|
|
19
19
|
/** Upload an image asset and return its sidecar-relative path. */
|
|
20
20
|
uploadAsset: (file: Blob, suggestedName?: string) => Promise<string>;
|
|
21
|
+
/** Shared image picker ref used by the toolbar and Layers panel. */
|
|
22
|
+
imageInputRef?: React.RefObject<HTMLInputElement>;
|
|
21
23
|
/** Trigger an export (PNG/JPEG/WebP) of the flattened canvas. */
|
|
22
24
|
onExport: (format: 'png' | 'jpeg' | 'webp') => void;
|
|
23
25
|
/** Force-flush the state.json (host's "save" button). */
|
|
@@ -142,6 +144,7 @@ export function Toolbar({
|
|
|
142
144
|
shapeKind,
|
|
143
145
|
dispatch,
|
|
144
146
|
uploadAsset,
|
|
147
|
+
imageInputRef,
|
|
145
148
|
onExport,
|
|
146
149
|
onSave,
|
|
147
150
|
saveLabel = 'Save',
|
|
@@ -154,7 +157,8 @@ export function Toolbar({
|
|
|
154
157
|
onZoomFit,
|
|
155
158
|
onZoom1to1,
|
|
156
159
|
}: ToolbarProps) {
|
|
157
|
-
const
|
|
160
|
+
const internalImageInputRef = useRef<HTMLInputElement>(null);
|
|
161
|
+
const fileInputRef = imageInputRef ?? internalImageInputRef;
|
|
158
162
|
const [shapePaletteOpen, setShapePaletteOpen] = useState(false);
|
|
159
163
|
|
|
160
164
|
const onFilePicked = async (file: File) => {
|
|
@@ -58,6 +58,10 @@ export function PlusIcon(props: Props) {
|
|
|
58
58
|
return <Icon icon="fa-solid fa-plus" {...props} />;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function ImageIcon(props: Props) {
|
|
62
|
+
return <Icon icon="fa-solid fa-image" {...props} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
61
65
|
export function NoneIcon(props: Props) {
|
|
62
66
|
// "No fill / transparent" affordance.
|
|
63
67
|
return <Icon icon="fa-solid fa-ban" {...props} />;
|
|
@@ -193,6 +193,73 @@
|
|
|
193
193
|
.squisq-image-editor-layers {
|
|
194
194
|
border-bottom: 1px solid var(--squisq-image-editor-panel-border);
|
|
195
195
|
}
|
|
196
|
+
.squisq-image-editor-layers-header {
|
|
197
|
+
display: flex;
|
|
198
|
+
align-items: center;
|
|
199
|
+
justify-content: space-between;
|
|
200
|
+
min-height: 32px;
|
|
201
|
+
}
|
|
202
|
+
.squisq-image-editor-layer-add {
|
|
203
|
+
position: relative;
|
|
204
|
+
display: inline-flex;
|
|
205
|
+
}
|
|
206
|
+
.squisq-image-editor-layer-add-button {
|
|
207
|
+
display: inline-flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
width: 22px;
|
|
211
|
+
height: 22px;
|
|
212
|
+
padding: 0;
|
|
213
|
+
color: inherit;
|
|
214
|
+
background: transparent;
|
|
215
|
+
border: 1px solid transparent;
|
|
216
|
+
border-radius: 3px;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
font: inherit;
|
|
219
|
+
}
|
|
220
|
+
.squisq-image-editor-layer-add-button:hover,
|
|
221
|
+
.squisq-image-editor-layer-add-button[aria-expanded='true'] {
|
|
222
|
+
background: var(--squisq-image-editor-control-bg);
|
|
223
|
+
border-color: var(--squisq-image-editor-control-border);
|
|
224
|
+
}
|
|
225
|
+
.squisq-image-editor-layer-add-menu {
|
|
226
|
+
position: absolute;
|
|
227
|
+
top: calc(100% + 4px);
|
|
228
|
+
right: 0;
|
|
229
|
+
z-index: 20;
|
|
230
|
+
min-width: 150px;
|
|
231
|
+
padding: 4px;
|
|
232
|
+
background: var(--squisq-image-editor-panel-bg);
|
|
233
|
+
border: 1px solid var(--squisq-image-editor-panel-border);
|
|
234
|
+
border-radius: 5px;
|
|
235
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
236
|
+
text-transform: none;
|
|
237
|
+
letter-spacing: normal;
|
|
238
|
+
}
|
|
239
|
+
.squisq-image-editor-layer-add-menu-item {
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
gap: 8px;
|
|
243
|
+
width: 100%;
|
|
244
|
+
padding: 6px 8px;
|
|
245
|
+
color: inherit;
|
|
246
|
+
background: transparent;
|
|
247
|
+
border: 0;
|
|
248
|
+
border-radius: 3px;
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
font: inherit;
|
|
251
|
+
font-size: 12px;
|
|
252
|
+
text-align: left;
|
|
253
|
+
}
|
|
254
|
+
.squisq-image-editor-layer-add-menu-item:hover,
|
|
255
|
+
.squisq-image-editor-layer-add-menu-item:focus-visible {
|
|
256
|
+
background: var(--squisq-image-editor-control-bg);
|
|
257
|
+
outline: none;
|
|
258
|
+
}
|
|
259
|
+
.squisq-image-editor-layer-add-menu-item svg {
|
|
260
|
+
flex: 0 0 auto;
|
|
261
|
+
width: 14px;
|
|
262
|
+
}
|
|
196
263
|
.squisq-image-editor-layer-list {
|
|
197
264
|
list-style: none;
|
|
198
265
|
margin: 0;
|
|
@@ -239,6 +306,7 @@
|
|
|
239
306
|
cursor: not-allowed;
|
|
240
307
|
}
|
|
241
308
|
.squisq-image-editor-layer-name {
|
|
309
|
+
min-width: 0;
|
|
242
310
|
flex: 1 1 auto;
|
|
243
311
|
text-align: left;
|
|
244
312
|
background: transparent;
|
|
@@ -254,7 +322,14 @@
|
|
|
254
322
|
text-overflow: ellipsis;
|
|
255
323
|
white-space: nowrap;
|
|
256
324
|
}
|
|
325
|
+
.squisq-image-editor-layer-name-text {
|
|
326
|
+
min-width: 0;
|
|
327
|
+
overflow: hidden;
|
|
328
|
+
text-overflow: ellipsis;
|
|
329
|
+
white-space: nowrap;
|
|
330
|
+
}
|
|
257
331
|
.squisq-image-editor-layer-kind {
|
|
332
|
+
flex: 0 0 auto;
|
|
258
333
|
margin-left: auto;
|
|
259
334
|
font-size: 10px;
|
|
260
335
|
color: var(--squisq-image-editor-text-muted);
|
|
@@ -35,7 +35,7 @@ export function SelectionHandles({ box, onHandlePointerDown }: Props) {
|
|
|
35
35
|
];
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
|
-
<g pointerEvents="none">
|
|
38
|
+
<g className="squisq-image-editor-selection-handles" pointerEvents="none">
|
|
39
39
|
{/*
|
|
40
40
|
* Outline: rendered as two stacked rectangles so the selection
|
|
41
41
|
* stays visible over both light and dark imagery. The white
|