@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.
@@ -10,7 +10,7 @@
10
10
  * - PreviewPanel (the actual player, which reads the selected values)
11
11
  */
12
12
 
13
- import { createContext, useContext, useState, useMemo, useEffect } from 'react';
13
+ import { createContext, useContext, useState, useMemo, useEffect, useRef } from 'react';
14
14
  import type { ReactNode } from 'react';
15
15
  import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
16
16
  import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
@@ -233,90 +233,132 @@ const selectStyle: React.CSSProperties = {
233
233
 
234
234
  // ── Toolbar Controls Component ───────────────────────────────────
235
235
 
236
+ /** Hook to track whether the viewport is narrow. */
237
+ function useIsNarrow(breakpoint = 600): boolean {
238
+ const [narrow, setNarrow] = useState(
239
+ () => typeof window !== 'undefined' && window.innerWidth <= breakpoint,
240
+ );
241
+ useEffect(() => {
242
+ const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
243
+ const handler = (e: MediaQueryListEvent) => setNarrow(e.matches);
244
+ mq.addEventListener('change', handler);
245
+ return () => mq.removeEventListener('change', handler);
246
+ }, [breakpoint]);
247
+ return narrow;
248
+ }
249
+
236
250
  /**
237
251
  * Inline preview controls rendered in the main toolbar row.
238
- * Reads from PreviewSettingsContext.
252
+ * On narrow viewports, collapses into a single settings button with a dropdown.
239
253
  */
240
254
  export function PreviewToolbarControls() {
241
255
  const s = usePreviewSettings();
242
-
243
- return (
244
- <div
245
- style={{
246
- display: 'flex',
247
- alignItems: 'center',
248
- gap: '6px',
249
- flexWrap: 'wrap',
250
- padding: '2px 0 2px 9px',
251
- }}
252
- >
253
- <label style={labelStyle}>Format:</label>
254
- <select
256
+ const isNarrow = useIsNarrow(768);
257
+ const [popoverOpen, setPopoverOpen] = useState(false);
258
+ const popoverRef = useRef<HTMLDivElement>(null);
259
+
260
+ // Close popover on outside click
261
+ useEffect(() => {
262
+ if (!popoverOpen) return;
263
+ const handler = (e: MouseEvent) => {
264
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
265
+ setPopoverOpen(false);
266
+ }
267
+ };
268
+ document.addEventListener('mousedown', handler);
269
+ return () => document.removeEventListener('mousedown', handler);
270
+ }, [popoverOpen]);
271
+
272
+ const controls = (
273
+ <>
274
+ <PreviewSelect
275
+ label="Format"
255
276
  value={s.activePreset}
256
- onChange={(e) => s.setSelectedPreset(e.target.value as ViewportPreset)}
257
- style={selectStyle}
258
- >
259
- {VIEWPORT_OPTIONS.map((o) => (
260
- <option key={o.key} value={o.key}>
261
- {o.label}
262
- </option>
263
- ))}
264
- </select>
265
-
266
- <Divider />
267
-
268
- <label style={labelStyle}>Mode:</label>
269
- <select
277
+ options={VIEWPORT_OPTIONS}
278
+ onChange={(v) => s.setSelectedPreset(v as ViewportPreset)}
279
+ compact={isNarrow}
280
+ />
281
+ <PreviewSelect
282
+ label="Mode"
270
283
  value={s.activeDisplayMode}
271
- onChange={(e) => s.setSelectedDisplayMode(e.target.value as DisplayMode)}
272
- style={selectStyle}
273
- >
274
- {DISPLAY_MODE_OPTIONS.map((o) => (
275
- <option key={o.key} value={o.key}>
276
- {o.label}
277
- </option>
278
- ))}
279
- </select>
280
-
281
- <Divider />
282
-
283
- <label style={labelStyle}>Theme:</label>
284
- <select
284
+ options={DISPLAY_MODE_OPTIONS}
285
+ onChange={(v) => s.setSelectedDisplayMode(v as DisplayMode)}
286
+ compact={isNarrow}
287
+ />
288
+ <PreviewSelect
289
+ label="Theme"
285
290
  value={s.activeThemeId}
286
- onChange={(e) => s.setSelectedThemeId(e.target.value)}
287
- style={selectStyle}
288
- >
289
- {THEME_OPTIONS.map((o) => (
290
- <option key={o.key} value={o.key}>
291
- {o.label}
292
- </option>
293
- ))}
294
- </select>
295
-
296
- <Divider />
297
-
298
- <label style={labelStyle}>Transform:</label>
299
- <select
291
+ options={THEME_OPTIONS}
292
+ onChange={(v) => s.setSelectedThemeId(v)}
293
+ compact={isNarrow}
294
+ />
295
+ <PreviewSelect
296
+ label="Transform"
300
297
  value={s.activeTransformStyle}
301
- onChange={(e) => s.setSelectedTransformStyle(e.target.value)}
302
- style={selectStyle}
303
- >
304
- {TRANSFORM_STYLE_OPTIONS.map((o) => (
305
- <option key={o.key} value={o.key}>
306
- {o.label}
307
- </option>
308
- ))}
309
- </select>
298
+ options={TRANSFORM_STYLE_OPTIONS}
299
+ onChange={(v) => s.setSelectedTransformStyle(v)}
300
+ compact={isNarrow}
301
+ />
302
+ <PreviewSelect
303
+ label="Captions"
304
+ value={s.activeCaptionStyle}
305
+ options={CAPTION_STYLE_OPTIONS}
306
+ onChange={(v) => s.setSelectedCaptionStyle(v as CaptionStyle)}
307
+ compact={isNarrow}
308
+ />
309
+ </>
310
+ );
310
311
 
311
- <Divider />
312
+ if (isNarrow) {
313
+ return (
314
+ <div className="squisq-preview-controls-compact" ref={popoverRef}>
315
+ <button
316
+ className="squisq-toolbar-button"
317
+ onClick={() => setPopoverOpen((v) => !v)}
318
+ aria-label="Preview settings"
319
+ title="Preview settings"
320
+ aria-expanded={popoverOpen}
321
+ >
322
+ <svg
323
+ width="16"
324
+ height="16"
325
+ viewBox="0 0 16 16"
326
+ fill="none"
327
+ stroke="currentColor"
328
+ strokeWidth="1.5"
329
+ strokeLinecap="round"
330
+ strokeLinejoin="round"
331
+ >
332
+ <circle cx="8" cy="8" r="2.5" />
333
+ <path d="M13.5 8a5.5 5.5 0 01-.4 1.8l1.2 1.2-1.6 1.6-1.2-1.2A5.5 5.5 0 018 13.5a5.5 5.5 0 01-3.5-1.3L3.3 13.4 1.7 11.8l1.2-1.2A5.5 5.5 0 012.5 8c0-.6.1-1.2.4-1.8L1.7 5 3.3 3.4l1.2 1.2A5.5 5.5 0 018 2.5c1.3 0 2.5.5 3.5 1.3l1.2-1.2 1.6 1.6-1.2 1.2c.3.6.4 1.2.4 1.6z" />
334
+ </svg>
335
+ </button>
336
+ {popoverOpen && <div className="squisq-preview-controls-popover">{controls}</div>}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ return <div className="squisq-preview-controls-inline">{controls}</div>;
342
+ }
312
343
 
313
- <label style={labelStyle}>Captions:</label>
314
- <select
315
- value={s.activeCaptionStyle}
316
- onChange={(e) => s.setSelectedCaptionStyle(e.target.value as CaptionStyle)}
317
- style={selectStyle}
318
- >
319
- {CAPTION_STYLE_OPTIONS.map((o) => (
344
+ function PreviewSelect({
345
+ label,
346
+ value,
347
+ options,
348
+ onChange,
349
+ compact,
350
+ }: {
351
+ label: string;
352
+ value: string;
353
+ options: { key: string; label: string }[];
354
+ onChange: (value: string) => void;
355
+ compact?: boolean;
356
+ }) {
357
+ return (
358
+ <div className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}>
359
+ <label style={labelStyle}>{label}:</label>
360
+ <select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle}>
361
+ {options.map((o) => (
320
362
  <option key={o.key} value={o.key}>
321
363
  {o.label}
322
364
  </option>
@@ -325,16 +367,3 @@ export function PreviewToolbarControls() {
325
367
  </div>
326
368
  );
327
369
  }
328
-
329
- function Divider() {
330
- return (
331
- <span
332
- style={{
333
- width: '1px',
334
- height: '16px',
335
- background: 'var(--squisq-border, #d1d5db)',
336
- margin: '0 2px',
337
- }}
338
- />
339
- );
340
- }
@@ -6,29 +6,20 @@
6
6
  *
7
7
  * The markdown-derived Doc (from markdownToDoc) contains hierarchical blocks
8
8
  * with template names, heading text, and body content — but no audio or
9
- * visual layers. This component bridges the gap by:
10
- *
11
- * 1. Flattening the block tree into a linear slide sequence
12
- * 2. Converting each block into a TemplateBlock-compatible object
13
- * (mapping heading text → title, templateOverrides → template fields)
14
- * 3. Synthesizing a dummy audio segment so DocPlayer's timing works
15
- * (the player enters fallback-timer mode when audio can't load)
16
- * 4. Passing the prepared Doc to DocPlayer for SVG-based rendering
9
+ * visual layers. This component bridges the gap by using buildPreviewDoc()
10
+ * to flatten blocks, convert them to TemplateBlock slides with interleaved
11
+ * images, and synthesize timing.
17
12
  */
18
13
 
19
14
  import { useState, useEffect } from 'react';
20
15
  import { DocPlayer, LinearDocView } from '@bendyline/squisq-react';
21
- import { flattenBlocks } from '@bendyline/squisq/doc';
22
- import { hasTemplate } from '@bendyline/squisq/doc';
23
- import { extractPlainText } from '@bendyline/squisq/markdown';
24
- import type { Block, Doc } from '@bendyline/squisq/schemas';
25
- import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
26
- import { getChildren } from '@bendyline/squisq/markdown';
16
+ import type { Doc } from '@bendyline/squisq/schemas';
27
17
  import { applyTransform } from '@bendyline/squisq/transform';
28
18
  import { resolveAudioMapping } from '@bendyline/squisq/doc';
29
19
  import type { ContentContainer } from '@bendyline/squisq/storage';
30
20
  import { useEditorContext } from './EditorContext';
31
21
  import { usePreviewSettings } from './PreviewControls';
22
+ import { buildPreviewDoc } from './buildPreviewDoc';
32
23
 
33
24
  export interface PreviewPanelProps {
34
25
  /** Base path for resolving media URLs in DocPlayer */
@@ -39,325 +30,6 @@ export interface PreviewPanelProps {
39
30
  container?: ContentContainer | null;
40
31
  }
41
32
 
42
- // ── Helpers ────────────────────────────────────────────────────────
43
-
44
- /**
45
- * Extract plain text from an array of markdown block nodes.
46
- * Walks paragraphs, blockquotes, and list items to collect all text.
47
- */
48
- function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
49
- if (!contents || contents.length === 0) return '';
50
- const parts: string[] = [];
51
- for (const node of contents) {
52
- parts.push(extractPlainText(node));
53
- }
54
- return parts.join('\n').trim();
55
- }
56
-
57
- /**
58
- * Extract images from a block's markdown contents.
59
- * Walks the node tree recursively to find all MarkdownImage nodes.
60
- */
61
- function extractBlockImages(
62
- contents: MarkdownBlockNode[] | undefined,
63
- ): Array<{ src: string; alt: string }> {
64
- if (!contents || contents.length === 0) return [];
65
- const images: Array<{ src: string; alt: string }> = [];
66
-
67
- function walk(node: MarkdownNode): void {
68
- if ('type' in node && node.type === 'image' && 'url' in node) {
69
- const img = node as { url: string; alt?: string };
70
- if (img.url) {
71
- images.push({ src: img.url, alt: img.alt ?? '' });
72
- }
73
- }
74
- for (const child of getChildren(node)) {
75
- walk(child);
76
- }
77
- }
78
-
79
- for (const node of contents) {
80
- walk(node);
81
- }
82
- return images;
83
- }
84
-
85
- /**
86
- * Collect all unique images from an entire Doc's block tree.
87
- * Walks nested children to find every image across all blocks.
88
- */
89
- function collectAllDocImages(blocks: Block[]): Array<{ src: string; alt: string }> {
90
- const seen = new Set<string>();
91
- const images: Array<{ src: string; alt: string }> = [];
92
-
93
- function walkBlocks(blockList: Block[]): void {
94
- for (const block of blockList) {
95
- for (const img of extractBlockImages(block.contents)) {
96
- if (!seen.has(img.src)) {
97
- seen.add(img.src);
98
- images.push(img);
99
- }
100
- }
101
- if (block.children) {
102
- walkBlocks(block.children);
103
- }
104
- }
105
- }
106
-
107
- walkBlocks(blocks);
108
- return images;
109
- }
110
-
111
- /**
112
- * Extract list items from markdown body content.
113
- * Returns an array of plain text strings for each list item found.
114
- */
115
- function extractListItems(contents: MarkdownBlockNode[] | undefined): string[] {
116
- if (!contents) return [];
117
- const items: string[] = [];
118
- for (const node of contents) {
119
- if (node.type === 'list') {
120
- for (const item of (node as MarkdownList).children) {
121
- const text = extractPlainText(item).trim();
122
- if (text) items.push(text);
123
- }
124
- }
125
- }
126
- return items;
127
- }
128
-
129
- /**
130
- * Provide sensible default fields for templates that require more than
131
- * just a `title`. This prevents crashes from undefined required fields
132
- * when the markdown annotations don't supply all template-specific values.
133
- */
134
- function getTemplateDefaults(
135
- templateName: string,
136
- headingText: string,
137
- block: Block,
138
- ): Record<string, unknown> {
139
- const body = extractBodyText(block.contents);
140
-
141
- switch (templateName) {
142
- case 'statHighlight':
143
- return {
144
- stat: headingText,
145
- description: body || headingText,
146
- };
147
-
148
- case 'quoteBlock':
149
- case 'fullBleedQuote':
150
- case 'pullQuote':
151
- return {
152
- quote: body || headingText,
153
- };
154
-
155
- case 'factCard':
156
- return {
157
- fact: headingText,
158
- explanation: body || headingText,
159
- };
160
-
161
- case 'comparisonBar':
162
- return {
163
- leftLabel: 'A',
164
- leftValue: 60,
165
- rightLabel: 'B',
166
- rightValue: 40,
167
- };
168
-
169
- case 'listBlock':
170
- return {
171
- items: extractListItems(block.contents) || ['Item 1', 'Item 2', 'Item 3'],
172
- };
173
-
174
- case 'definitionCard':
175
- return {
176
- term: headingText,
177
- definition: body || headingText,
178
- };
179
-
180
- case 'dateEvent':
181
- return {
182
- date: headingText,
183
- description: body || headingText,
184
- };
185
-
186
- default:
187
- return {};
188
- }
189
- }
190
-
191
- /**
192
- * Convert a markdown-derived Block into a TemplateBlock-compatible object.
193
- *
194
- * The block's heading text becomes `title` (works for sectionHeader,
195
- * titleBlock, factCard, etc.). Any templateOverrides from annotation
196
- * syntax `{[template key=value]}` are spread on top so template-specific
197
- * fields (stat, quote, description, …) are available.
198
- *
199
- * If the requested template doesn't exist in the registry, falls back
200
- * to `sectionHeader` to avoid "Unknown template" warnings.
201
- */
202
- function blockToSlide(block: Block, index: number): Record<string, unknown> {
203
- const headingText = block.sourceHeading
204
- ? extractPlainText(block.sourceHeading)
205
- : block.title || block.id || `Slide ${index + 1}`;
206
-
207
- // Validate template name — fall back to sectionHeader for unknowns
208
- const requestedTemplate = block.template || 'sectionHeader';
209
- const template = hasTemplate(requestedTemplate) ? requestedTemplate : 'sectionHeader';
210
-
211
- // Get sensible defaults for templates that need more than just `title`
212
- const defaults = getTemplateDefaults(template, headingText, block);
213
-
214
- // Spread the block itself to pick up any template-specific fields
215
- // placed directly on the block by applyTransform (e.g. stat, description,
216
- // quote, colorScheme). These are not in templateOverrides — they live
217
- // on the block object because the transform produces hybrid Block+Template
218
- // objects via the timing allocator.
219
- const {
220
- id: _id,
221
- startTime: _st,
222
- duration: _d,
223
- audioSegment: _as,
224
- layers: _l,
225
- transition: _tr,
226
- template: _t,
227
- title: _ti,
228
- children: _c,
229
- contents: _co,
230
- sourceHeading: _sh,
231
- templateOverrides: _to,
232
- ...extraFields
233
- } = block as unknown as Record<string, unknown>;
234
-
235
- return {
236
- id: block.id,
237
- template,
238
- duration: block.duration,
239
- audioSegment: 0,
240
- transition: index > 0 ? { type: 'fade', duration: 0.5 } : undefined,
241
- // Provide heading text as title — consumed by sectionHeader, titleBlock, etc.
242
- title: headingText,
243
- // Template-specific defaults (safe fallbacks for required fields)
244
- ...defaults,
245
- // Template-specific fields from transform (stat, description, quote, etc.)
246
- ...extraFields,
247
- // Spread annotation overrides last so explicit values win
248
- ...block.templateOverrides,
249
- };
250
- }
251
-
252
- /** Ambient motions to rotate on image slides. */
253
- const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
254
- 'zoomIn',
255
- 'zoomOut',
256
- 'panLeft',
257
- 'panRight',
258
- ];
259
-
260
- /**
261
- * Build a player-ready Doc from the markdown-derived Doc.
262
- *
263
- * Flattens hierarchical blocks, converts each to a TemplateBlock-compatible
264
- * slide, recalculates timing, and adds a synthetic audio segment.
265
- *
266
- * Images found in the markdown are used in two ways:
267
- * 1. Per-block: if a block has images, its first image becomes the background
268
- * (via imageWithCaption template) or an accent image on text templates.
269
- * 2. Global: remaining images are interleaved as standalone image slides
270
- * for visual variety.
271
- */
272
- function buildPreviewDoc(doc: Doc): Doc {
273
- const flat = flattenBlocks(doc.blocks);
274
-
275
- // Collect all images from the doc for global interleaving
276
- const allImages = collectAllDocImages(doc.blocks);
277
-
278
- // Track which images are used per-block so we can interleave the rest
279
- const usedImageSrcs = new Set<string>();
280
-
281
- // First pass: convert blocks to slides, using per-block images
282
- const slides: Record<string, unknown>[] = [];
283
- let motionIndex = 0;
284
-
285
- for (let i = 0; i < flat.length; i++) {
286
- const block = flat[i];
287
- const blockImages = extractBlockImages(block.contents);
288
- const slide = blockToSlide(block, i);
289
-
290
- // If the block has images and is using the default sectionHeader template,
291
- // upgrade it to imageWithCaption so the image becomes the slide background.
292
- if (blockImages.length > 0 && slide.template === 'sectionHeader') {
293
- const img = blockImages[0];
294
- usedImageSrcs.add(img.src);
295
- slide.template = 'imageWithCaption';
296
- slide.imageSrc = img.src;
297
- slide.imageAlt = img.alt;
298
- slide.caption = slide.title as string;
299
- slide.captionPosition = 'bottom';
300
- slide.ambientMotion = IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length];
301
- } else if (blockImages.length > 0) {
302
- // For other templates, add the first image as an accent
303
- const img = blockImages[0];
304
- usedImageSrcs.add(img.src);
305
- if (!slide.accentImage) {
306
- slide.accentImage = {
307
- src: img.src,
308
- alt: img.alt,
309
- position: 'left-strip',
310
- ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
311
- };
312
- }
313
- }
314
-
315
- slides.push(slide);
316
- }
317
-
318
- // Second pass: interleave unused images as standalone imageWithCaption slides.
319
- // Spread them evenly through the sequence for visual variety.
320
- const unusedImages = allImages.filter((img) => !usedImageSrcs.has(img.src));
321
- if (unusedImages.length > 0 && slides.length > 0) {
322
- const interval = Math.max(2, Math.floor(slides.length / (unusedImages.length + 1)));
323
- let insertOffset = 0;
324
- for (let imgIdx = 0; imgIdx < unusedImages.length; imgIdx++) {
325
- const insertAt = Math.min((imgIdx + 1) * interval + insertOffset, slides.length);
326
- const img = unusedImages[imgIdx];
327
- slides.splice(insertAt, 0, {
328
- id: `img-interleave-${imgIdx}`,
329
- template: 'imageWithCaption',
330
- duration: 5,
331
- audioSegment: 0,
332
- imageSrc: img.src,
333
- imageAlt: img.alt,
334
- ambientMotion: IMAGE_MOTIONS[motionIndex++ % IMAGE_MOTIONS.length],
335
- transition: { type: 'fade', duration: 0.5 },
336
- });
337
- insertOffset++;
338
- }
339
- }
340
-
341
- // Recalculate sequential timing
342
- let t = 0;
343
- for (const slide of slides) {
344
- slide.startTime = t;
345
- t += slide.duration as number;
346
- }
347
-
348
- return {
349
- articleId: doc.articleId,
350
- duration: t,
351
- blocks: slides as unknown as Block[],
352
- audio: {
353
- // Synthetic segment — audio will fail to load and DocPlayer will use
354
- // its fallback timer to advance currentTime via requestAnimationFrame.
355
- segments: t > 0 ? [{ src: '', name: 'preview', duration: t, startTime: 0 }] : [],
356
- },
357
- ...(doc.captions ? { captions: doc.captions } : {}),
358
- };
359
- }
360
-
361
33
  // ── Component ──────────────────────────────────────────────────────
362
34
 
363
35
  /**
package/src/RawEditor.tsx CHANGED
@@ -12,7 +12,16 @@ import * as monaco from 'monaco-editor';
12
12
  import { useEditorContext } from './EditorContext';
13
13
  import { getAvailableTemplates } from '@bendyline/squisq/doc';
14
14
 
15
- // Use locally installed monaco-editor instead of CDN
15
+ // Use locally installed monaco-editor instead of CDN.
16
+ //
17
+ // NOTE: By default this imports the full monaco-editor with all 80+ languages
18
+ // and workers (~9MB). Consumers can dramatically reduce bundle size by aliasing
19
+ // 'monaco-editor' to a slim entry in their bundler config. For example with Vite:
20
+ //
21
+ // resolve: { alias: [{ find: /^monaco-editor$/, replacement: './monaco-slim.ts' }] }
22
+ //
23
+ // Where monaco-slim.ts re-exports 'monaco-editor/esm/vs/editor/editor.api' plus
24
+ // only the language contributions needed (e.g. markdown, javascript, etc.).
16
25
  loader.config({ monaco });
17
26
 
18
27
  export interface RawEditorProps {
package/src/Toolbar.tsx CHANGED
@@ -150,13 +150,28 @@ export function Toolbar({
150
150
  // Hidden file input for image picker
151
151
  const imageInputRef = useRef<HTMLInputElement>(null);
152
152
 
153
+ // ── Narrow-screen detection ──────────────────────────
154
+ const [isNarrow, setIsNarrow] = useState(
155
+ () => typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches,
156
+ );
157
+ useEffect(() => {
158
+ const mq = window.matchMedia('(max-width: 768px)');
159
+ const handler = (e: MediaQueryListEvent) => setIsNarrow(e.matches);
160
+ mq.addEventListener('change', handler);
161
+ return () => mq.removeEventListener('change', handler);
162
+ }, []);
163
+
153
164
  // ── Overflow detection ────────────────────────────────
154
165
  const actionsRef = useRef<HTMLDivElement>(null);
155
- const [overflowIndex, setOverflowIndex] = useState<number | null>(null);
166
+ const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
156
167
  const [showOverflow, setShowOverflow] = useState(false);
157
168
  const overflowRef = useRef<HTMLDivElement>(null);
158
169
 
170
+ // On narrow screens, force all buttons into the overflow menu
171
+ const overflowIndex = isNarrow ? 0 : measuredOverflowIndex;
172
+
159
173
  useEffect(() => {
174
+ if (isNarrow) return; // Skip measurement on narrow — everything overflows
160
175
  const container = actionsRef.current;
161
176
  if (!container) return;
162
177
 
@@ -174,14 +189,14 @@ export function Toolbar({
174
189
  firstHidden = i;
175
190
  }
176
191
  });
177
- setOverflowIndex(firstHidden);
192
+ setMeasuredOverflowIndex(firstHidden);
178
193
  };
179
194
 
180
195
  const ro = new ResizeObserver(measure);
181
196
  ro.observe(container);
182
197
  measure();
183
198
  return () => ro.disconnect();
184
- }, [activeView]);
199
+ }, [activeView, isNarrow]);
185
200
 
186
201
  // Close overflow menu on outside click
187
202
  useEffect(() => {
@@ -538,8 +553,8 @@ export function Toolbar({
538
553
  </button>
539
554
  ))}
540
555
  </div>
541
- {/* Formatting buttons — hidden in preview mode */}
542
- {!isPreview && (
556
+ {/* Formatting buttons — hidden in preview mode and on narrow screens */}
557
+ {!isPreview && !isNarrow && (
543
558
  <div className="squisq-toolbar-actions" ref={actionsRef}>
544
559
  {groups.map((group, gi) => (
545
560
  <div key={group} className="squisq-toolbar-group">