@bendyline/squisq-editor-react 1.5.3 → 1.6.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 +57 -10
- package/dist/index.d.ts +1114 -52
- package/dist/index.js +18446 -8050
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/previewControls.test.tsx +70 -0
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blockRange
|
|
3
|
+
*
|
|
4
|
+
* Source-text-range slicing for the block-at-a-time editing view. Splits a
|
|
5
|
+
* full markdown document into ordered, contiguous slices — one per
|
|
6
|
+
* heading-defined block plus an optional leading preamble — so a single
|
|
7
|
+
* block can be shown in isolation and edits spliced back into the parent.
|
|
8
|
+
*
|
|
9
|
+
* A block runs from its heading line through the character just before the
|
|
10
|
+
* next heading at ANY depth (or EOF). Sub-headings therefore start their own
|
|
11
|
+
* slices — they are NOT folded into their parent — which matches the
|
|
12
|
+
* "don't see child blocks" requirement and the `slicePastHeading` boundary
|
|
13
|
+
* in `blockSlice.ts`. Ranges are half-open `[startOffset, endOffset)` and
|
|
14
|
+
* line-aligned, so trailing blank lines stay with the current block and
|
|
15
|
+
* `spliceBlock(src, range, getBlockSlices(src)[i].text) === src` for every i.
|
|
16
|
+
*
|
|
17
|
+
* These are pure functions over a markdown string — no React, no editor
|
|
18
|
+
* coupling — so any host can reuse them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
22
|
+
import type { MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
23
|
+
import { frontmatterEndOffset } from './frontmatter';
|
|
24
|
+
|
|
25
|
+
/** Half-open character range into the full source: `[startOffset, endOffset)`. */
|
|
26
|
+
export interface BlockRange {
|
|
27
|
+
startOffset: number;
|
|
28
|
+
endOffset: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One block's source text plus the range it occupies in the full document. */
|
|
32
|
+
export interface BlockSlice {
|
|
33
|
+
text: string;
|
|
34
|
+
range: BlockRange;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Map every line start to its character offset (`lineStarts[line - 1]`). */
|
|
38
|
+
function computeLineStarts(source: string): number[] {
|
|
39
|
+
const starts = [0];
|
|
40
|
+
for (let i = 0; i < source.length; i++) {
|
|
41
|
+
if (source[i] === '\n') starts.push(i + 1);
|
|
42
|
+
}
|
|
43
|
+
return starts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeSlice(source: string, startOffset: number, endOffset: number): BlockSlice {
|
|
47
|
+
return { text: source.slice(startOffset, endOffset), range: { startOffset, endOffset } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split `fullSource` into ordered block slices.
|
|
52
|
+
*
|
|
53
|
+
* - With no headings, the entire post-frontmatter body is a single slice
|
|
54
|
+
* (so an empty or heading-less document still shows one editable card).
|
|
55
|
+
* - With headings, a leading preamble slice is included only when the text
|
|
56
|
+
* before the first heading has non-whitespace content. Each heading then
|
|
57
|
+
* yields one slice spanning up to the next heading (any depth) or EOF.
|
|
58
|
+
*
|
|
59
|
+
* Frontmatter is never part of any slice — slices start at the body offset.
|
|
60
|
+
*/
|
|
61
|
+
export function getBlockSlices(fullSource: string): BlockSlice[] {
|
|
62
|
+
const bodyStart = frontmatterEndOffset(fullSource);
|
|
63
|
+
|
|
64
|
+
let doc: MarkdownDocument;
|
|
65
|
+
try {
|
|
66
|
+
doc = parseMarkdown(fullSource);
|
|
67
|
+
} catch {
|
|
68
|
+
// Unparseable mid-edit — treat the whole body as one slice rather than
|
|
69
|
+
// dropping the user into an empty card.
|
|
70
|
+
return [makeSlice(fullSource, bodyStart, fullSource.length)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lineStarts = computeLineStarts(fullSource);
|
|
74
|
+
const headingOffsets: number[] = [];
|
|
75
|
+
for (const node of doc.children) {
|
|
76
|
+
if (node.type !== 'heading') continue;
|
|
77
|
+
const line = node.position?.start.line;
|
|
78
|
+
if (typeof line !== 'number') continue;
|
|
79
|
+
const off = lineStarts[line - 1];
|
|
80
|
+
if (typeof off === 'number') headingOffsets.push(off);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (headingOffsets.length === 0) {
|
|
84
|
+
return [makeSlice(fullSource, bodyStart, fullSource.length)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const slices: BlockSlice[] = [];
|
|
88
|
+
const firstHeading = headingOffsets[0];
|
|
89
|
+
if (fullSource.slice(bodyStart, firstHeading).trim().length > 0) {
|
|
90
|
+
slices.push(makeSlice(fullSource, bodyStart, firstHeading));
|
|
91
|
+
}
|
|
92
|
+
for (let i = 0; i < headingOffsets.length; i++) {
|
|
93
|
+
const start = headingOffsets[i];
|
|
94
|
+
const end = i + 1 < headingOffsets.length ? headingOffsets[i + 1] : fullSource.length;
|
|
95
|
+
slices.push(makeSlice(fullSource, start, end));
|
|
96
|
+
}
|
|
97
|
+
return slices;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Replace the text in `range` with `newText`, returning the new full source. */
|
|
101
|
+
export function spliceBlock(fullSource: string, range: BlockRange, newText: string): string {
|
|
102
|
+
return fullSource.slice(0, range.startOffset) + newText + fullSource.slice(range.endOffset);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Character offset of the start of 1-based `line` (clamps past EOF). */
|
|
106
|
+
export function lineToOffset(source: string, line: number): number {
|
|
107
|
+
const starts = computeLineStarts(source);
|
|
108
|
+
return starts[Math.max(0, line - 1)] ?? source.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 1-based line number containing `offset`. */
|
|
112
|
+
export function offsetToLine(source: string, offset: number): number {
|
|
113
|
+
const starts = computeLineStarts(source);
|
|
114
|
+
let lo = 0;
|
|
115
|
+
let hi = starts.length - 1;
|
|
116
|
+
let ans = 0;
|
|
117
|
+
while (lo <= hi) {
|
|
118
|
+
const mid = (lo + hi) >> 1;
|
|
119
|
+
if (starts[mid] <= offset) {
|
|
120
|
+
ans = mid;
|
|
121
|
+
lo = mid + 1;
|
|
122
|
+
} else {
|
|
123
|
+
hi = mid - 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return ans + 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Index of the slice whose range contains `offset`, or -1. */
|
|
130
|
+
export function sliceIndexAtOffset(slices: BlockSlice[], offset: number): number {
|
|
131
|
+
return slices.findIndex((s) => offset >= s.range.startOffset && offset < s.range.endOffset);
|
|
132
|
+
}
|
package/src/buildPreviewDoc.ts
CHANGED
|
@@ -11,19 +11,40 @@
|
|
|
11
11
|
* 4. Synthesize a dummy audio segment for timer-based playback
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
14
|
+
import { flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
|
|
15
|
+
import { extractPlainText, KNOWN_BLOCK_META_KEYS } from '@bendyline/squisq/markdown';
|
|
16
16
|
import { getChildren } from '@bendyline/squisq/markdown';
|
|
17
|
+
import { iconMarker } from '@bendyline/squisq/icon-marker';
|
|
18
|
+
import type { IconFamily } from '@bendyline/squisq/icons';
|
|
17
19
|
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
18
20
|
import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
|
|
19
21
|
|
|
20
22
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Like `extractPlainText`, but preserves inline icons as encoded markers so
|
|
26
|
+
* template text can render them as glyphs downstream (see `iconMarker` and
|
|
27
|
+
* `TextLayer`). Mirrors `extractPlainText`'s value/child/list handling; the
|
|
28
|
+
* only addition is the `inlineIcon` interception.
|
|
29
|
+
*/
|
|
30
|
+
function extractRichText(node: MarkdownNode): string {
|
|
31
|
+
if (node.type === 'inlineIcon') {
|
|
32
|
+
const icon = node as unknown as { family: IconFamily; name: string };
|
|
33
|
+
return iconMarker(icon.family, icon.name);
|
|
34
|
+
}
|
|
35
|
+
if ('value' in node && typeof (node as { value?: unknown }).value === 'string') {
|
|
36
|
+
return (node as { value: string }).value;
|
|
37
|
+
}
|
|
38
|
+
const children = getChildren(node);
|
|
39
|
+
const separator = node.type === 'list' || node.type === 'listItem' ? '\n' : '';
|
|
40
|
+
return children.map(extractRichText).join(separator);
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
|
|
23
44
|
if (!contents || contents.length === 0) return '';
|
|
24
45
|
const parts: string[] = [];
|
|
25
46
|
for (const node of contents) {
|
|
26
|
-
parts.push(
|
|
47
|
+
parts.push(extractRichText(node));
|
|
27
48
|
}
|
|
28
49
|
return parts.join('\n').trim();
|
|
29
50
|
}
|
|
@@ -174,13 +195,23 @@ function getTemplateDefaults(
|
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
|
|
177
|
-
function blockToSlide(
|
|
198
|
+
function blockToSlide(
|
|
199
|
+
block: Block,
|
|
200
|
+
index: number,
|
|
201
|
+
knownTemplates?: ReadonlySet<string>,
|
|
202
|
+
): Record<string, unknown> {
|
|
178
203
|
const headingText = block.sourceHeading
|
|
179
204
|
? extractPlainText(block.sourceHeading)
|
|
180
205
|
: block.title || block.id || `Slide ${index + 1}`;
|
|
181
206
|
|
|
182
207
|
const requestedTemplate = block.template || 'sectionHeader';
|
|
183
|
-
|
|
208
|
+
// A template is recognized if it's a built-in OR a user-defined
|
|
209
|
+
// template carried in the doc's `customTemplates` set. Without this,
|
|
210
|
+
// an annotated `{[hero]}` heading would silently fall back to
|
|
211
|
+
// `sectionHeader` because `hasTemplate` only knows built-ins.
|
|
212
|
+
const isCustomTemplate = knownTemplates?.has(requestedTemplate) ?? false;
|
|
213
|
+
const recognized = hasTemplate(requestedTemplate) || isCustomTemplate;
|
|
214
|
+
const template = recognized ? requestedTemplate : 'sectionHeader';
|
|
184
215
|
const defaults = getTemplateDefaults(template, headingText, block);
|
|
185
216
|
|
|
186
217
|
const {
|
|
@@ -196,6 +227,7 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
196
227
|
contents: _co,
|
|
197
228
|
sourceHeading: _sh,
|
|
198
229
|
templateOverrides: _to,
|
|
230
|
+
templateData: _td,
|
|
199
231
|
...extraFields
|
|
200
232
|
} = block as unknown as Record<string, unknown>;
|
|
201
233
|
|
|
@@ -204,14 +236,59 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
204
236
|
template,
|
|
205
237
|
duration: block.duration,
|
|
206
238
|
audioSegment: 0,
|
|
207
|
-
|
|
239
|
+
// Respect the block's authored transition (set via the toolbar / on-canvas
|
|
240
|
+
// properties palette → `{…}` block attrs). Only fall back to a default fade
|
|
241
|
+
// for blocks past the first when the author hasn't chosen one; the first
|
|
242
|
+
// block has no previous slide to transition in from.
|
|
243
|
+
transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
|
|
208
244
|
title: headingText,
|
|
245
|
+
// Custom templates need access to the source block's body content
|
|
246
|
+
// + children so their token resolver (`{content}`, `{children}`,
|
|
247
|
+
// `{image:N}`) substitutes against the user's prose, not just the
|
|
248
|
+
// heading. Built-in templates don't read these fields and risk
|
|
249
|
+
// surprising overlap with their typed inputs, so we only attach
|
|
250
|
+
// them when the slide actually maps to a custom template.
|
|
251
|
+
...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
|
|
252
|
+
...(isCustomTemplate && block.children ? { children: block.children } : {}),
|
|
209
253
|
...defaults,
|
|
210
254
|
...extraFields,
|
|
211
|
-
|
|
255
|
+
// Structured body data (```json data fences, GFM tables for dataTable)
|
|
256
|
+
// carries typed values; `{[…]}` string overrides win last so an explicit
|
|
257
|
+
// annotation param can still pin any field.
|
|
258
|
+
//
|
|
259
|
+
// Block-meta keys (transition, startTime, duration, …) are the exception:
|
|
260
|
+
// they were already coerced to typed block fields above (e.g.
|
|
261
|
+
// `block.transition` → `{ type, duration, direction }`). Their raw string
|
|
262
|
+
// form also rides along in `templateData`/`templateOverrides` because the
|
|
263
|
+
// author wrote them inside `{[…]}`; left un-stripped, that string would
|
|
264
|
+
// spread back over the typed value here and clobber it — turning
|
|
265
|
+
// `transition=vortex` into the string `"vortex"`, which the player can't
|
|
266
|
+
// animate. Omit them from the content spreads so the typed fields win.
|
|
267
|
+
...omitBlockMeta(block.templateData),
|
|
268
|
+
...omitBlockMeta(block.templateOverrides),
|
|
212
269
|
};
|
|
213
270
|
}
|
|
214
271
|
|
|
272
|
+
/** Keys coerced to typed block fields; must not be re-applied as raw strings. */
|
|
273
|
+
const BLOCK_META_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_BLOCK_META_KEYS));
|
|
274
|
+
|
|
275
|
+
/** Copy of a template-param record with block-meta keys removed. */
|
|
276
|
+
function omitBlockMeta(
|
|
277
|
+
data: Record<string, unknown> | undefined,
|
|
278
|
+
): Record<string, unknown> | undefined {
|
|
279
|
+
if (!data) return data;
|
|
280
|
+
let hit = false;
|
|
281
|
+
const out: Record<string, unknown> = {};
|
|
282
|
+
for (const key of Object.keys(data)) {
|
|
283
|
+
if (BLOCK_META_KEYS.has(key)) {
|
|
284
|
+
hit = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
out[key] = data[key];
|
|
288
|
+
}
|
|
289
|
+
return hit ? out : data;
|
|
290
|
+
}
|
|
291
|
+
|
|
215
292
|
const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
216
293
|
'zoomIn',
|
|
217
294
|
'zoomOut',
|
|
@@ -229,9 +306,18 @@ const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
|
229
306
|
* audio segment.
|
|
230
307
|
*/
|
|
231
308
|
export function buildPreviewDoc(doc: Doc): Doc {
|
|
232
|
-
|
|
309
|
+
// Container templates (`diagram`, `drawing`) render their children as
|
|
310
|
+
// nodes/shapes, so those children must not also become preview slides.
|
|
311
|
+
const flat = flattenRenderableBlocks(doc.blocks);
|
|
233
312
|
const allImages = collectAllDocImages(doc.blocks);
|
|
234
313
|
const usedImageSrcs = new Set<string>();
|
|
314
|
+
// Names of user-defined templates carried by the doc — passed into
|
|
315
|
+
// `blockToSlide` so heading annotations like `{[hero]}` aren't
|
|
316
|
+
// silently downgraded to `sectionHeader` when the template doesn't
|
|
317
|
+
// exist in the built-in registry.
|
|
318
|
+
const knownTemplates = doc.customTemplates
|
|
319
|
+
? new Set(doc.customTemplates.map((d) => d.name))
|
|
320
|
+
: undefined;
|
|
235
321
|
|
|
236
322
|
const slides: Record<string, unknown>[] = [];
|
|
237
323
|
let motionIndex = 0;
|
|
@@ -239,7 +325,7 @@ export function buildPreviewDoc(doc: Doc): Doc {
|
|
|
239
325
|
for (let i = 0; i < flat.length; i++) {
|
|
240
326
|
const block = flat[i];
|
|
241
327
|
const blockImages = extractBlockImages(block.contents);
|
|
242
|
-
const slide = blockToSlide(block, i);
|
|
328
|
+
const slide = blockToSlide(block, i, knownTemplates);
|
|
243
329
|
|
|
244
330
|
if (blockImages.length > 0 && slide.template === 'sectionHeader') {
|
|
245
331
|
const img = blockImages[0];
|
|
@@ -305,5 +391,8 @@ export function buildPreviewDoc(doc: Doc): Doc {
|
|
|
305
391
|
...(doc.captions ? { captions: doc.captions } : {}),
|
|
306
392
|
...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
|
|
307
393
|
...(doc.themeId ? { themeId: doc.themeId } : {}),
|
|
394
|
+
// Custom templates ride along so `useDocPlayback` can merge them
|
|
395
|
+
// onto the registry before expanding slides.
|
|
396
|
+
...(doc.customTemplates ? { customTemplates: doc.customTemplates } : {}),
|
|
308
397
|
};
|
|
309
398
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
2
|
+
import { MarkdownRenderer } from '@bendyline/squisq-react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import type { CodeContext, CodeContextSection } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One context section rendered inside a Monaco view zone: a single-line
|
|
8
|
+
* disclosure strip, plus the full markdown body while expanded. The body is
|
|
9
|
+
* rendered lazily — a file with 100 collapsed sections parses 100 one-liners,
|
|
10
|
+
* nothing more.
|
|
11
|
+
*/
|
|
12
|
+
export interface CodeContextSectionViewProps {
|
|
13
|
+
section: Omit<CodeContextSection, 'line'>;
|
|
14
|
+
expanded: boolean;
|
|
15
|
+
onToggle: (id: string) => void;
|
|
16
|
+
linkSchemes?: readonly string[] | undefined;
|
|
17
|
+
onLinkClick?: CodeContext['onLinkClick'] | undefined;
|
|
18
|
+
/** Native `#L<n>` handling: reveal that line in the editor. */
|
|
19
|
+
onRevealLine: (line: number) => void;
|
|
20
|
+
/** Reports the rendered content height so the zone can be resized to fit. */
|
|
21
|
+
onMeasure: (id: string, px: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CodeContextSectionView({
|
|
25
|
+
section,
|
|
26
|
+
expanded,
|
|
27
|
+
onToggle,
|
|
28
|
+
linkSchemes,
|
|
29
|
+
onLinkClick,
|
|
30
|
+
onRevealLine,
|
|
31
|
+
onMeasure,
|
|
32
|
+
}: CodeContextSectionViewProps) {
|
|
33
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
|
|
35
|
+
const stripNodes = useMemo(
|
|
36
|
+
() => parseMarkdown(section.summaryMarkdown).children,
|
|
37
|
+
[section.summaryMarkdown],
|
|
38
|
+
);
|
|
39
|
+
const bodyNodes = useMemo(
|
|
40
|
+
() => (expanded && section.markdown ? parseMarkdown(section.markdown).children : null),
|
|
41
|
+
[expanded, section.markdown],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Height feedback loop: report the content's real height whenever it
|
|
45
|
+
// changes. Monaco display:none's offscreen zones and ResizeObserver reports
|
|
46
|
+
// 0×0 for those — ignore zeros so scrolled-away zones keep their height.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const el = rootRef.current;
|
|
49
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
50
|
+
const report = () => {
|
|
51
|
+
const h = el.offsetHeight;
|
|
52
|
+
if (h > 0) onMeasure(section.id, h);
|
|
53
|
+
};
|
|
54
|
+
report();
|
|
55
|
+
const ro = new ResizeObserver(report);
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, [section.id, onMeasure]);
|
|
59
|
+
|
|
60
|
+
// Delegated link interception. `#L<n>` reveals natively; everything else
|
|
61
|
+
// goes to the host callback (returning false opts back into default
|
|
62
|
+
// navigation).
|
|
63
|
+
const handleClick = useCallback(
|
|
64
|
+
(e: React.MouseEvent) => {
|
|
65
|
+
const anchor = (e.target as HTMLElement).closest?.('a');
|
|
66
|
+
if (!anchor) return;
|
|
67
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
68
|
+
const lineMatch = /^#L(\d+)$/.exec(href);
|
|
69
|
+
if (lineMatch) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
onRevealLine(Number(lineMatch[1]));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (onLinkClick) {
|
|
76
|
+
const handled = onLinkClick(href, { sectionId: section.id });
|
|
77
|
+
if (handled !== false) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
[onLinkClick, onRevealLine, section.id],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Keep Monaco's container-level mousedown handler from hijacking clicks
|
|
87
|
+
// and text selection inside the section.
|
|
88
|
+
const stopMouseDown = useCallback((e: React.MouseEvent) => {
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
// Click handler is delegated anchor interception only; keyboard users
|
|
94
|
+
// reach links natively and the strip itself is a real <button>.
|
|
95
|
+
<div
|
|
96
|
+
ref={rootRef}
|
|
97
|
+
className={`squisq-ccx-section${expanded ? ' squisq-ccx-section--expanded' : ''}`}
|
|
98
|
+
onClick={handleClick}
|
|
99
|
+
onMouseDown={stopMouseDown}
|
|
100
|
+
>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="squisq-ccx-strip"
|
|
104
|
+
aria-expanded={expanded}
|
|
105
|
+
onClick={() => onToggle(section.id)}
|
|
106
|
+
>
|
|
107
|
+
<span className="squisq-ccx-chevron" aria-hidden="true">
|
|
108
|
+
{expanded ? '▾' : '▸'}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="squisq-ccx-strip-text">
|
|
111
|
+
<MarkdownRenderer nodes={stripNodes} {...(linkSchemes ? { linkSchemes } : {})} />
|
|
112
|
+
</span>
|
|
113
|
+
</button>
|
|
114
|
+
{expanded &&
|
|
115
|
+
(bodyNodes ? (
|
|
116
|
+
<div className="squisq-ccx-body">
|
|
117
|
+
<MarkdownRenderer nodes={bodyNodes} {...(linkSchemes ? { linkSchemes } : {})} />
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="squisq-ccx-body squisq-ccx-body--loading">Loading…</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { editor as MonacoEditorNs } from 'monaco-editor';
|
|
2
|
+
import { type ZoneSpec, diffContextSections } from './diffContextSections';
|
|
3
|
+
|
|
4
|
+
type MonacoEditor = MonacoEditorNs.IStandaloneCodeEditor;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Owns the Monaco view zones behind the code-context sections. React-free —
|
|
8
|
+
* the React layer portals content into the zone dom nodes it hands out and
|
|
9
|
+
* feeds measured heights back via {@link setHeight}.
|
|
10
|
+
*
|
|
11
|
+
* Zone mechanics this leans on (monaco 0.50):
|
|
12
|
+
* - `changeViewZones` batches adds/removes/layouts into one whitespace
|
|
13
|
+
* re-layout.
|
|
14
|
+
* - `layoutZone(id)` re-reads the zone delegate, so mutating our own
|
|
15
|
+
* delegate's `afterLineNumber`/`heightInPx` then calling layoutZone both
|
|
16
|
+
* moves and resizes without remove/re-add churn (expanded DOM survives).
|
|
17
|
+
* - Zone dom nodes live in `.view-zones` (absolutely positioned, offscreen
|
|
18
|
+
* ones display:none'd by Monaco). They sit BELOW `.view-lines` in hit-test
|
|
19
|
+
* order, so the stylesheet raises `.squisq-ccx-zone` with a z-index and
|
|
20
|
+
* `pointer-events: auto` to make section content clickable.
|
|
21
|
+
* - Model swaps invalidate zone ids; the owner listens to onDidChangeModel
|
|
22
|
+
* and rebuilds.
|
|
23
|
+
*/
|
|
24
|
+
export class CodeContextZoneManager {
|
|
25
|
+
private readonly editor: MonacoEditor;
|
|
26
|
+
private entries = new Map<
|
|
27
|
+
string,
|
|
28
|
+
{
|
|
29
|
+
zoneId: string;
|
|
30
|
+
delegate: MonacoEditorNs.IViewZone;
|
|
31
|
+
domNode: HTMLDivElement;
|
|
32
|
+
spec: ZoneSpec;
|
|
33
|
+
}
|
|
34
|
+
>();
|
|
35
|
+
private listeners = new Set<() => void>();
|
|
36
|
+
private modelListener: { dispose(): void } | null = null;
|
|
37
|
+
private disposed = false;
|
|
38
|
+
|
|
39
|
+
/** Initial height estimate for a strip; the ResizeObserver corrects it. */
|
|
40
|
+
static readonly INITIAL_HEIGHT_PX = 28;
|
|
41
|
+
|
|
42
|
+
constructor(editor: MonacoEditor) {
|
|
43
|
+
this.editor = editor;
|
|
44
|
+
// A model swap (file change / external reset) invalidates every zone id.
|
|
45
|
+
// Drop our bookkeeping; the next sync() recreates zones against the new
|
|
46
|
+
// model.
|
|
47
|
+
this.modelListener = editor.onDidChangeModel(() => {
|
|
48
|
+
this.entries.clear();
|
|
49
|
+
this.emit();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reconcile the live zones against `specs` in one changeViewZones batch. */
|
|
54
|
+
sync(specs: ZoneSpec[]): void {
|
|
55
|
+
if (this.disposed) return;
|
|
56
|
+
const prev = [...this.entries.values()].map((e) => e.spec);
|
|
57
|
+
const { add, remove, move } = diffContextSections(prev, specs);
|
|
58
|
+
if (add.length === 0 && remove.length === 0 && move.length === 0) return;
|
|
59
|
+
|
|
60
|
+
this.editor.changeViewZones((accessor) => {
|
|
61
|
+
for (const id of remove) {
|
|
62
|
+
const entry = this.entries.get(id);
|
|
63
|
+
if (!entry) continue;
|
|
64
|
+
accessor.removeZone(entry.zoneId);
|
|
65
|
+
this.entries.delete(id);
|
|
66
|
+
}
|
|
67
|
+
for (const spec of move) {
|
|
68
|
+
const entry = this.entries.get(spec.id);
|
|
69
|
+
if (!entry) continue;
|
|
70
|
+
entry.spec = spec;
|
|
71
|
+
entry.delegate.afterLineNumber = Math.max(spec.line - 1, 0);
|
|
72
|
+
setOrdinal(entry.delegate, spec.ordinal);
|
|
73
|
+
accessor.layoutZone(entry.zoneId);
|
|
74
|
+
}
|
|
75
|
+
for (const spec of add) {
|
|
76
|
+
const domNode = document.createElement('div');
|
|
77
|
+
domNode.className = 'squisq-ccx-zone';
|
|
78
|
+
domNode.dataset.sectionId = spec.id;
|
|
79
|
+
const delegate: MonacoEditorNs.IViewZone = {
|
|
80
|
+
afterLineNumber: Math.max(spec.line - 1, 0),
|
|
81
|
+
heightInPx: CodeContextZoneManager.INITIAL_HEIGHT_PX,
|
|
82
|
+
domNode,
|
|
83
|
+
suppressMouseDown: true,
|
|
84
|
+
};
|
|
85
|
+
setOrdinal(delegate, spec.ordinal);
|
|
86
|
+
const zoneId = accessor.addZone(delegate);
|
|
87
|
+
this.entries.set(spec.id, { zoneId, delegate, domNode, spec });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
this.emit();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Measured-content feedback: resize the zone to fit its rendered DOM. */
|
|
94
|
+
setHeight(id: string, px: number): void {
|
|
95
|
+
if (this.disposed) return;
|
|
96
|
+
const entry = this.entries.get(id);
|
|
97
|
+
if (!entry) return;
|
|
98
|
+
const height = Math.max(Math.ceil(px), 1);
|
|
99
|
+
if (Math.abs((entry.delegate.heightInPx ?? 0) - height) < 1) return;
|
|
100
|
+
entry.delegate.heightInPx = height;
|
|
101
|
+
this.editor.changeViewZones((accessor) => {
|
|
102
|
+
accessor.layoutZone(entry.zoneId);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getDomNode(id: string): HTMLDivElement | undefined {
|
|
107
|
+
return this.entries.get(id)?.domNode;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
liveIds(): string[] {
|
|
111
|
+
return [...this.entries.keys()];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fires after sync/model-change so the React layer rebuilds its portals. */
|
|
115
|
+
onDidChangeZones(cb: () => void): () => void {
|
|
116
|
+
this.listeners.add(cb);
|
|
117
|
+
return () => this.listeners.delete(cb);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
dispose(): void {
|
|
121
|
+
if (this.disposed) return;
|
|
122
|
+
this.disposed = true;
|
|
123
|
+
this.modelListener?.dispose();
|
|
124
|
+
this.modelListener = null;
|
|
125
|
+
if (this.entries.size > 0) {
|
|
126
|
+
try {
|
|
127
|
+
this.editor.changeViewZones((accessor) => {
|
|
128
|
+
for (const entry of this.entries.values()) accessor.removeZone(entry.zoneId);
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// Editor already disposed — its zones died with it.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.entries.clear();
|
|
135
|
+
this.listeners.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private emit(): void {
|
|
139
|
+
for (const cb of [...this.listeners]) cb();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `ordinal` orders zones sharing an afterLineNumber (monaco 0.50 supports it
|
|
145
|
+
* but the public IViewZone typing lags behind) — hence the structural write.
|
|
146
|
+
*/
|
|
147
|
+
function setOrdinal(zone: MonacoEditorNs.IViewZone, ordinal: number): void {
|
|
148
|
+
(zone as MonacoEditorNs.IViewZone & { ordinal?: number }).ordinal = ordinal;
|
|
149
|
+
}
|