@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.
- package/README.md +12 -3
- package/dist/index.d.ts +1304 -208
- package/dist/index.js +22838 -14494
- 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 +2384 -408
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +48 -17
- package/src/DocumentSettingsDialog.tsx +13 -21
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +403 -270
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +276 -40
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +466 -93
- package/src/PreviewPanel.tsx +313 -77
- package/src/RawEditor.tsx +13 -2
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +34 -10
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +355 -134
- package/src/ThemeCustomizerPanel.tsx +30 -15
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +719 -238
- package/src/TransitionPicker.tsx +44 -5
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +167 -92
- 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 +22 -3
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +390 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +53 -15
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +38 -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__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/mediaReferences.test.ts +82 -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 +312 -6
- 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__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +70 -0
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -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 +224 -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 +227 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
- package/src/asciiDiagram/asciiDiagramData.ts +116 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +219 -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/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/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- 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 +9 -4
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/headingTransition.ts +85 -25
- 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 +193 -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/mediaReferences.ts +299 -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/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +101 -25
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +11 -4
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- 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 +93 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/commands/SceneCommand.ts +4 -1
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +24 -9
- package/src/scene/layers/edgeGeometry.ts +31 -4
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +145 -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 +126 -28
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +10 -0
- package/src/scene/tools/SelectTool.ts +34 -18
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +499 -0
- package/src/styles/editor.css +1566 -219
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +139 -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 +360 -0
- package/src/timeline/TimelineEditorWidget.tsx +702 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
- package/src/timeline/__tests__/timelineOps.test.ts +222 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +367 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +277 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +84 -49
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +240 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +125 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
- package/src/treeview/treeOps.ts +172 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +94 -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 -270
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -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';
|
package/src/headingTransition.ts
CHANGED
|
@@ -6,16 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - Markdown (Monaco): operate on the raw heading line string.
|
|
8
8
|
* - WYSIWYG (Tiptap): operate on the heading node's `dataBlockAttrs` string
|
|
9
|
-
* (
|
|
10
|
-
* `
|
|
9
|
+
* (Pandoc `{…}` inner) and `dataTemplateParams` string (the params inside
|
|
10
|
+
* the squisq-native `{[…]}` annotation).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* value set here round-trips through a Doc render without being duplicated
|
|
17
|
-
* or moved. Reads still look at the `{[…]}` params too, so a hand-typed
|
|
18
|
-
* `{[title transition=fade]}` shows up in the picker.
|
|
12
|
+
* The editor writes transitions to the squisq-native `{[…]}` annotation by
|
|
13
|
+
* default. It still reads legacy Pandoc `{transition=…}` attributes and
|
|
14
|
+
* removes/migrates those keys when rewriting, so the two channels cannot drift
|
|
15
|
+
* after a toolbar edit.
|
|
19
16
|
*
|
|
20
17
|
* All the brace-matching / tokenizing / serializing is delegated to the
|
|
21
18
|
* shared core helpers so this stays in lockstep with the parser by import
|
|
@@ -29,6 +26,7 @@ import {
|
|
|
29
26
|
serializePandocAttributes,
|
|
30
27
|
tokenizeAttrTokens,
|
|
31
28
|
splitKeyValueToken,
|
|
29
|
+
quoteAttrValue,
|
|
32
30
|
type HeadingAttributes,
|
|
33
31
|
} from '@bendyline/squisq/markdown';
|
|
34
32
|
|
|
@@ -85,6 +83,14 @@ function applyFieldsToParams(
|
|
|
85
83
|
return out;
|
|
86
84
|
}
|
|
87
85
|
|
|
86
|
+
function removeFieldsFromAttrs(attrs: HeadingAttributes): HeadingAttributes {
|
|
87
|
+
if (!attrs.params) return attrs;
|
|
88
|
+
const params = applyFieldsToParams(attrs.params, EMPTY_TRANSITION);
|
|
89
|
+
if (Object.keys(params).length > 0) attrs.params = params;
|
|
90
|
+
else delete attrs.params;
|
|
91
|
+
return attrs;
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
/** Parse a bare `key=value …` token string into a params map. */
|
|
89
95
|
function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<string, string> {
|
|
90
96
|
const tokens = tokenizeAttrTokens(input);
|
|
@@ -96,6 +102,39 @@ function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<s
|
|
|
96
102
|
return params;
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
function templatePartsFromInner(inner: string | null | undefined): {
|
|
106
|
+
template: string | undefined;
|
|
107
|
+
params: Record<string, string>;
|
|
108
|
+
} {
|
|
109
|
+
if (!inner) return { template: undefined, params: {} };
|
|
110
|
+
const tokens = tokenizeAttrTokens(inner);
|
|
111
|
+
const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
|
|
112
|
+
const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
|
|
113
|
+
const startIdx = template ? 1 : 0;
|
|
114
|
+
const params: Record<string, string> = {};
|
|
115
|
+
for (const token of tokens.slice(startIdx)) {
|
|
116
|
+
const kv = splitKeyValueToken(token);
|
|
117
|
+
if (kv) params[kv.key] = kv.value;
|
|
118
|
+
}
|
|
119
|
+
return { template, params };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function paramsToInner(params: Record<string, string>): string | null {
|
|
123
|
+
const parts = Object.entries(params).map(([key, value]) => `${key}=${quoteAttrValue(value)}`);
|
|
124
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function templateAnnotationOrNull(
|
|
128
|
+
template: string | undefined,
|
|
129
|
+
params: Record<string, string>,
|
|
130
|
+
): string | null {
|
|
131
|
+
const parts: string[] = [];
|
|
132
|
+
if (template) parts.push(template);
|
|
133
|
+
const paramInner = paramsToInner(params);
|
|
134
|
+
if (paramInner) parts.push(paramInner);
|
|
135
|
+
return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
99
138
|
// ============================================
|
|
100
139
|
// Heading line (Markdown / Monaco view)
|
|
101
140
|
// ============================================
|
|
@@ -141,7 +180,7 @@ function splitHeadingLine(line: string): SplitHeadingLine | null {
|
|
|
141
180
|
|
|
142
181
|
/**
|
|
143
182
|
* Read the transition fields off a heading line. Looks in both the Pandoc
|
|
144
|
-
* `{…}` block (
|
|
183
|
+
* `{…}` block (legacy) and the `{[…]}` template params (canonical),
|
|
145
184
|
* with the Pandoc block taking precedence. Returns the empty transition for
|
|
146
185
|
* non-heading lines.
|
|
147
186
|
*/
|
|
@@ -149,7 +188,7 @@ export function readHeadingLineTransition(line: string): TransitionFields {
|
|
|
149
188
|
const split = splitHeadingLine(line);
|
|
150
189
|
if (!split) return { ...EMPTY_TRANSITION };
|
|
151
190
|
const fromTemplate = split.templateText
|
|
152
|
-
?
|
|
191
|
+
? templatePartsFromInner(stripTemplateBraces(split.templateText)).params
|
|
153
192
|
: {};
|
|
154
193
|
const fromPandoc = split.pandocInner
|
|
155
194
|
? (parsePandocAttrTokens(split.pandocInner).params ?? {})
|
|
@@ -159,8 +198,9 @@ export function readHeadingLineTransition(line: string): TransitionFields {
|
|
|
159
198
|
|
|
160
199
|
/**
|
|
161
200
|
* Return `line` with its transition rewritten from `next`, writing into the
|
|
162
|
-
*
|
|
163
|
-
* Non-heading lines are
|
|
201
|
+
* squisq-native `{[…]}` annotation. Legacy Pandoc transition keys are removed
|
|
202
|
+
* while preserving ids, classes, and other Pandoc params. Non-heading lines are
|
|
203
|
+
* returned unchanged.
|
|
164
204
|
*/
|
|
165
205
|
export function setHeadingLineTransition(line: string, next: TransitionFields): string {
|
|
166
206
|
const split = splitHeadingLine(line);
|
|
@@ -168,12 +208,18 @@ export function setHeadingLineTransition(line: string, next: TransitionFields):
|
|
|
168
208
|
const attrs: HeadingAttributes = split.pandocInner
|
|
169
209
|
? parsePandocAttrTokens(split.pandocInner)
|
|
170
210
|
: {};
|
|
171
|
-
|
|
211
|
+
removeFieldsFromAttrs(attrs);
|
|
172
212
|
const pandoc = pandocBlockOrNull(attrs);
|
|
173
213
|
|
|
214
|
+
const templateParts = templatePartsFromInner(
|
|
215
|
+
split.templateText ? stripTemplateBraces(split.templateText) : null,
|
|
216
|
+
);
|
|
217
|
+
const templateParams = applyFieldsToParams(templateParts.params, next);
|
|
218
|
+
const template = templateAnnotationOrNull(templateParts.template, templateParams);
|
|
219
|
+
|
|
174
220
|
let out = split.prefix + split.text;
|
|
175
221
|
if (pandoc) out += ` ${pandoc}`;
|
|
176
|
-
if (
|
|
222
|
+
if (template) out += ` ${template}`;
|
|
177
223
|
return out;
|
|
178
224
|
}
|
|
179
225
|
|
|
@@ -188,8 +234,10 @@ function stripTemplateBraces(templateText: string): string {
|
|
|
188
234
|
// ============================================
|
|
189
235
|
|
|
190
236
|
/**
|
|
191
|
-
* Read the transition fields from a heading node's `dataBlockAttrs` (
|
|
192
|
-
* inner) plus `dataTemplateParams` (
|
|
237
|
+
* Read the transition fields from a heading node's `dataBlockAttrs` (legacy
|
|
238
|
+
* Pandoc inner) plus `dataTemplateParams` (canonical `{[…]}` params). Pandoc
|
|
239
|
+
* wins so the picker mirrors the value that `markdownToDoc` will render when
|
|
240
|
+
* both channels are present.
|
|
193
241
|
*/
|
|
194
242
|
export function readBlockAttrsTransition(
|
|
195
243
|
blockAttrsInner: string | null | undefined,
|
|
@@ -200,20 +248,32 @@ export function readBlockAttrsTransition(
|
|
|
200
248
|
return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
|
|
201
249
|
}
|
|
202
250
|
|
|
251
|
+
export interface HeadingTransitionAttrs {
|
|
252
|
+
/** Inner of the Pandoc `{…}` block, without braces. */
|
|
253
|
+
blockAttrsInner: string | null;
|
|
254
|
+
/** Param string inside the `{[…]}` annotation, without the template token. */
|
|
255
|
+
templateParams: string | null;
|
|
256
|
+
}
|
|
257
|
+
|
|
203
258
|
/**
|
|
204
|
-
* Rewrite
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* (absent attribute → null, not `{}`).
|
|
259
|
+
* Rewrite a heading node's transition for Tiptap, writing the transition
|
|
260
|
+
* family into `dataTemplateParams` and removing any legacy transition keys
|
|
261
|
+
* from `dataBlockAttrs`.
|
|
208
262
|
*/
|
|
209
|
-
export function
|
|
263
|
+
export function setHeadingAttrsTransition(
|
|
210
264
|
blockAttrsInner: string | null | undefined,
|
|
265
|
+
templateParams: string | null | undefined,
|
|
211
266
|
next: TransitionFields,
|
|
212
|
-
):
|
|
267
|
+
): HeadingTransitionAttrs {
|
|
213
268
|
const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
|
|
214
|
-
|
|
269
|
+
removeFieldsFromAttrs(attrs);
|
|
215
270
|
const pandoc = pandocBlockOrNull(attrs);
|
|
216
|
-
|
|
271
|
+
|
|
272
|
+
const params = applyFieldsToParams(paramsFromTokenString(templateParams ?? '', false), next);
|
|
273
|
+
return {
|
|
274
|
+
blockAttrsInner: pandoc ? pandoc.slice(1, -1) : null,
|
|
275
|
+
templateParams: paramsToInner(params),
|
|
276
|
+
};
|
|
217
277
|
}
|
|
218
278
|
|
|
219
279
|
// ============================================
|
|
@@ -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} />;
|