@bendyline/squisq-editor-react 1.2.0 → 1.2.2
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/dist/PreviewControls.d.ts +1 -1
- package/dist/PreviewControls.d.ts.map +1 -1
- package/dist/PreviewControls.js +36 -17
- package/dist/PreviewControls.js.map +1 -1
- package/dist/PreviewPanel.d.ts +3 -8
- package/dist/PreviewPanel.d.ts.map +1 -1
- package/dist/PreviewPanel.js +4 -282
- package/dist/PreviewPanel.js.map +1 -1
- package/dist/RawEditor.d.ts.map +1 -1
- package/dist/RawEditor.js +10 -1
- package/dist/RawEditor.js.map +1 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar.js +16 -4
- package/dist/Toolbar.js.map +1 -1
- package/dist/buildPreviewDoc.d.ts +22 -0
- package/dist/buildPreviewDoc.d.ts.map +1 -0
- package/dist/buildPreviewDoc.js +212 -0
- package/dist/buildPreviewDoc.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +23 -23
- package/src/PreviewControls.tsx +116 -87
- package/src/PreviewPanel.tsx +5 -333
- package/src/RawEditor.tsx +10 -1
- package/src/Toolbar.tsx +20 -5
- package/src/buildPreviewDoc.ts +254 -0
- package/src/index.ts +3 -0
- package/src/styles/editor.css +57 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildPreviewDoc — Converts a markdown-derived Doc into a player-ready Doc
|
|
3
|
+
* with TemplateBlock slides and interleaved images.
|
|
4
|
+
*
|
|
5
|
+
* Shared between PreviewPanel (live preview) and export flows (HTML/video).
|
|
6
|
+
*
|
|
7
|
+
* Pipeline:
|
|
8
|
+
* 1. Flatten hierarchical blocks into a linear slide sequence
|
|
9
|
+
* 2. Convert each block into a TemplateBlock-compatible object
|
|
10
|
+
* 3. Interleave images as standalone imageWithCaption slides
|
|
11
|
+
* 4. Synthesize a dummy audio segment for timer-based playback
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { flattenBlocks, hasTemplate } from '@bendyline/squisq/doc';
|
|
15
|
+
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
16
|
+
import { getChildren } from '@bendyline/squisq/markdown';
|
|
17
|
+
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
18
|
+
import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
|
|
19
|
+
|
|
20
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
|
|
23
|
+
if (!contents || contents.length === 0) return '';
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
for (const node of contents) {
|
|
26
|
+
parts.push(extractPlainText(node));
|
|
27
|
+
}
|
|
28
|
+
return parts.join('\n').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractBlockImages(
|
|
32
|
+
contents: MarkdownBlockNode[] | undefined,
|
|
33
|
+
): Array<{ src: string; alt: string }> {
|
|
34
|
+
if (!contents || contents.length === 0) return [];
|
|
35
|
+
const images: Array<{ src: string; alt: string }> = [];
|
|
36
|
+
|
|
37
|
+
function walk(node: MarkdownNode): void {
|
|
38
|
+
if ('type' in node && node.type === 'image' && 'url' in node) {
|
|
39
|
+
const img = node as { url: string; alt?: string };
|
|
40
|
+
if (img.url) {
|
|
41
|
+
images.push({ src: img.url, alt: img.alt ?? '' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const child of getChildren(node)) {
|
|
45
|
+
walk(child);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const node of contents) {
|
|
50
|
+
walk(node);
|
|
51
|
+
}
|
|
52
|
+
return images;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectAllDocImages(blocks: Block[]): Array<{ src: string; alt: string }> {
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const images: Array<{ src: string; alt: string }> = [];
|
|
58
|
+
|
|
59
|
+
function walkBlocks(blockList: Block[]): void {
|
|
60
|
+
for (const block of blockList) {
|
|
61
|
+
for (const img of extractBlockImages(block.contents)) {
|
|
62
|
+
if (!seen.has(img.src)) {
|
|
63
|
+
seen.add(img.src);
|
|
64
|
+
images.push(img);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (block.children) {
|
|
68
|
+
walkBlocks(block.children);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
walkBlocks(blocks);
|
|
74
|
+
return images;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractListItems(contents: MarkdownBlockNode[] | undefined): string[] {
|
|
78
|
+
if (!contents) return [];
|
|
79
|
+
const items: string[] = [];
|
|
80
|
+
for (const node of contents) {
|
|
81
|
+
if (node.type === 'list') {
|
|
82
|
+
for (const item of (node as MarkdownList).children) {
|
|
83
|
+
const text = extractPlainText(item).trim();
|
|
84
|
+
if (text) items.push(text);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getTemplateDefaults(
|
|
92
|
+
templateName: string,
|
|
93
|
+
headingText: string,
|
|
94
|
+
block: Block,
|
|
95
|
+
): Record<string, unknown> {
|
|
96
|
+
const body = extractBodyText(block.contents);
|
|
97
|
+
|
|
98
|
+
switch (templateName) {
|
|
99
|
+
case 'statHighlight':
|
|
100
|
+
return { stat: headingText, description: body || headingText };
|
|
101
|
+
case 'quoteBlock':
|
|
102
|
+
case 'fullBleedQuote':
|
|
103
|
+
case 'pullQuote':
|
|
104
|
+
return { quote: body || headingText };
|
|
105
|
+
case 'factCard':
|
|
106
|
+
return { fact: headingText, explanation: body || headingText };
|
|
107
|
+
case 'comparisonBar':
|
|
108
|
+
return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
|
|
109
|
+
case 'listBlock': {
|
|
110
|
+
const items = extractListItems(block.contents);
|
|
111
|
+
return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
|
|
112
|
+
}
|
|
113
|
+
case 'definitionCard':
|
|
114
|
+
return { term: headingText, definition: body || headingText };
|
|
115
|
+
case 'dateEvent':
|
|
116
|
+
return { date: headingText, description: body || headingText };
|
|
117
|
+
default:
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
123
|
+
const headingText = block.sourceHeading
|
|
124
|
+
? extractPlainText(block.sourceHeading)
|
|
125
|
+
: block.title || block.id || `Slide ${index + 1}`;
|
|
126
|
+
|
|
127
|
+
const requestedTemplate = block.template || 'sectionHeader';
|
|
128
|
+
const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
|
|
129
|
+
const defaults = getTemplateDefaults(template, headingText, block);
|
|
130
|
+
|
|
131
|
+
const {
|
|
132
|
+
id: _id,
|
|
133
|
+
startTime: _st,
|
|
134
|
+
duration: _d,
|
|
135
|
+
audioSegment: _as,
|
|
136
|
+
layers: _l,
|
|
137
|
+
transition: _tr,
|
|
138
|
+
template: _t,
|
|
139
|
+
title: _ti,
|
|
140
|
+
children: _c,
|
|
141
|
+
contents: _co,
|
|
142
|
+
sourceHeading: _sh,
|
|
143
|
+
templateOverrides: _to,
|
|
144
|
+
...extraFields
|
|
145
|
+
} = block as unknown as Record<string, unknown>;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id: block.id,
|
|
149
|
+
template,
|
|
150
|
+
duration: block.duration,
|
|
151
|
+
audioSegment: 0,
|
|
152
|
+
transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
|
|
153
|
+
title: headingText,
|
|
154
|
+
...defaults,
|
|
155
|
+
...extraFields,
|
|
156
|
+
...block.templateOverrides,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
161
|
+
'zoomIn',
|
|
162
|
+
'zoomOut',
|
|
163
|
+
'panLeft',
|
|
164
|
+
'panRight',
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build a player-ready Doc from a markdown-derived Doc.
|
|
171
|
+
*
|
|
172
|
+
* Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
|
|
173
|
+
* slide, interleaves images, recalculates timing, and adds a synthetic
|
|
174
|
+
* audio segment.
|
|
175
|
+
*/
|
|
176
|
+
export function buildPreviewDoc(doc: Doc): Doc {
|
|
177
|
+
const flat = flattenBlocks(doc.blocks);
|
|
178
|
+
const allImages = collectAllDocImages(doc.blocks);
|
|
179
|
+
const usedImageSrcs = new Set<string>();
|
|
180
|
+
|
|
181
|
+
const slides: Record<string, unknown>[] = [];
|
|
182
|
+
let motionIndex = 0;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < flat.length; i++) {
|
|
185
|
+
const block = flat[i];
|
|
186
|
+
const blockImages = extractBlockImages(block.contents);
|
|
187
|
+
const slide = blockToSlide(block, i);
|
|
188
|
+
|
|
189
|
+
if (blockImages.length > 0 && slide.template === 'sectionHeader') {
|
|
190
|
+
const img = blockImages[0];
|
|
191
|
+
usedImageSrcs.add(img.src);
|
|
192
|
+
slide.template = 'imageWithCaption';
|
|
193
|
+
slide.imageSrc = img.src;
|
|
194
|
+
slide.imageAlt = img.alt;
|
|
195
|
+
slide.caption = slide.title as string;
|
|
196
|
+
slide.captionPosition = 'bottom';
|
|
197
|
+
slide.ambientMotion = IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length];
|
|
198
|
+
} else if (blockImages.length > 0) {
|
|
199
|
+
const img = blockImages[0];
|
|
200
|
+
usedImageSrcs.add(img.src);
|
|
201
|
+
if (!slide.accentImage) {
|
|
202
|
+
slide.accentImage = {
|
|
203
|
+
src: img.src,
|
|
204
|
+
alt: img.alt,
|
|
205
|
+
position: 'left-strip',
|
|
206
|
+
ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
slides.push(slide);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Interleave unused images
|
|
215
|
+
const unusedImages = allImages.filter((img) => !usedImageSrcs.has(img.src));
|
|
216
|
+
if (unusedImages.length > 0 && slides.length > 0) {
|
|
217
|
+
const interval = Math.max(2, Math.floor(slides.length / (unusedImages.length + 1)));
|
|
218
|
+
let insertOffset = 0;
|
|
219
|
+
for (let imgIdx = 0; imgIdx < unusedImages.length; imgIdx++) {
|
|
220
|
+
const insertAt = Math.min((imgIdx + 1) * interval + insertOffset, slides.length);
|
|
221
|
+
const img = unusedImages[imgIdx];
|
|
222
|
+
slides.splice(insertAt, 0, {
|
|
223
|
+
id: `img-interleave-${imgIdx}`,
|
|
224
|
+
template: 'imageWithCaption',
|
|
225
|
+
duration: 5,
|
|
226
|
+
audioSegment: 0,
|
|
227
|
+
imageSrc: img.src,
|
|
228
|
+
imageAlt: img.alt,
|
|
229
|
+
ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
|
|
230
|
+
transition: { type: 'fade', duration: 0.5 },
|
|
231
|
+
});
|
|
232
|
+
insertOffset++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Recalculate timing
|
|
237
|
+
let t = 0;
|
|
238
|
+
for (const slide of slides) {
|
|
239
|
+
slide.startTime = t;
|
|
240
|
+
t += slide.duration as number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
articleId: doc.articleId,
|
|
245
|
+
duration: t,
|
|
246
|
+
blocks: slides as unknown as Block[],
|
|
247
|
+
audio: {
|
|
248
|
+
segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
|
|
249
|
+
},
|
|
250
|
+
...(doc.captions ? { captions: doc.captions } : {}),
|
|
251
|
+
...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
|
|
252
|
+
...(doc.themeId ? { themeId: doc.themeId } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -83,5 +83,8 @@ export {
|
|
|
83
83
|
// Bridge utilities
|
|
84
84
|
export { markdownToTiptap, tiptapToMarkdown } from './tiptapBridge.js';
|
|
85
85
|
|
|
86
|
+
// Slideshow builder (shared between PreviewPanel and export flows)
|
|
87
|
+
export { buildPreviewDoc } from './buildPreviewDoc.js';
|
|
88
|
+
|
|
86
89
|
// Tiptap extension: Heading with template annotation support
|
|
87
90
|
export { HeadingWithTemplate } from './TemplateAnnotation.js';
|
package/src/styles/editor.css
CHANGED
|
@@ -170,6 +170,14 @@
|
|
|
170
170
|
color: #1d4ed8;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/* ─── Toolbar: narrow screen adjustments ───────────────── */
|
|
174
|
+
|
|
175
|
+
@media (max-width: 768px) {
|
|
176
|
+
.squisq-toolbar-view-tabs {
|
|
177
|
+
padding: 0 8px;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
173
181
|
/* ─── Toolbar Overflow Menu ──────────────────────────── */
|
|
174
182
|
|
|
175
183
|
.squisq-toolbar-overflow {
|
|
@@ -525,6 +533,55 @@
|
|
|
525
533
|
color: #b91c1c;
|
|
526
534
|
}
|
|
527
535
|
|
|
536
|
+
/* ─── Preview Controls ──────────────────────────────── */
|
|
537
|
+
|
|
538
|
+
.squisq-preview-controls-inline {
|
|
539
|
+
display: flex;
|
|
540
|
+
align-items: center;
|
|
541
|
+
gap: 6px;
|
|
542
|
+
flex-wrap: wrap;
|
|
543
|
+
padding: 2px 0 2px 9px;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.squisq-preview-controls-inline .squisq-preview-control {
|
|
547
|
+
display: flex;
|
|
548
|
+
align-items: center;
|
|
549
|
+
gap: 4px;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.squisq-preview-controls-compact {
|
|
553
|
+
position: relative;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.squisq-preview-controls-popover {
|
|
557
|
+
position: absolute;
|
|
558
|
+
top: 100%;
|
|
559
|
+
right: 0;
|
|
560
|
+
z-index: 80;
|
|
561
|
+
background: var(--squisq-bg, #fff);
|
|
562
|
+
border: 1px solid var(--squisq-border, #d1d5db);
|
|
563
|
+
border-radius: 6px;
|
|
564
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
565
|
+
padding: 8px 12px;
|
|
566
|
+
margin-top: 4px;
|
|
567
|
+
display: flex;
|
|
568
|
+
flex-direction: column;
|
|
569
|
+
gap: 8px;
|
|
570
|
+
min-width: 180px;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.squisq-preview-control--compact {
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
justify-content: space-between;
|
|
577
|
+
gap: 8px;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.squisq-preview-control--compact select {
|
|
581
|
+
flex: 1;
|
|
582
|
+
min-width: 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
528
585
|
.squisq-wysiwyg-editor a {
|
|
529
586
|
color: #2563eb;
|
|
530
587
|
text-decoration: underline;
|