@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.
@@ -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';
@@ -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;