@bendyline/squisq-react 1.2.0 → 1.3.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/dist/InlineAudioPlayer.d.ts +15 -0
- package/dist/InlineAudioPlayer.d.ts.map +1 -0
- package/dist/InlineAudioPlayer.js +22 -0
- package/dist/InlineAudioPlayer.js.map +1 -0
- package/dist/InlineVideoPlayer.d.ts +21 -0
- package/dist/InlineVideoPlayer.d.ts.map +1 -0
- package/dist/InlineVideoPlayer.js +29 -0
- package/dist/InlineVideoPlayer.js.map +1 -0
- package/dist/LinearDocView.d.ts.map +1 -1
- package/dist/LinearDocView.js +124 -13
- package/dist/LinearDocView.js.map +1 -1
- package/dist/MarkdownRenderer.d.ts.map +1 -1
- package/dist/MarkdownRenderer.js +111 -2
- package/dist/MarkdownRenderer.js.map +1 -1
- package/dist/SocialCaptionOverlay.d.ts.map +1 -1
- package/dist/SocialCaptionOverlay.js +3 -2
- package/dist/SocialCaptionOverlay.js.map +1 -1
- package/dist/__tests__/JsonView.test.d.ts +2 -0
- package/dist/__tests__/JsonView.test.d.ts.map +1 -0
- package/dist/__tests__/JsonView.test.js +93 -0
- package/dist/__tests__/JsonView.test.js.map +1 -0
- package/dist/__tests__/LinearDocView.test.js +62 -0
- package/dist/__tests__/LinearDocView.test.js.map +1 -1
- package/dist/hooks/MediaContext.d.ts.map +1 -1
- package/dist/hooks/MediaContext.js +13 -7
- package/dist/hooks/MediaContext.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/jsonView/JsonView.d.ts +26 -0
- package/dist/jsonView/JsonView.d.ts.map +1 -0
- package/dist/jsonView/JsonView.js +12 -0
- package/dist/jsonView/JsonView.js.map +1 -0
- package/dist/jsonView/RenderNode.d.ts +22 -0
- package/dist/jsonView/RenderNode.d.ts.map +1 -0
- package/dist/jsonView/RenderNode.js +30 -0
- package/dist/jsonView/RenderNode.js.map +1 -0
- package/dist/jsonView/index.d.ts +3 -0
- package/dist/jsonView/index.d.ts.map +1 -0
- package/dist/jsonView/index.js +2 -0
- package/dist/jsonView/index.js.map +1 -0
- package/dist/jsonView/useJsonViewTokens.d.ts +14 -0
- package/dist/jsonView/useJsonViewTokens.d.ts.map +1 -0
- package/dist/jsonView/useJsonViewTokens.js +34 -0
- package/dist/jsonView/useJsonViewTokens.js.map +1 -0
- package/dist/jsonView/viewers.d.ts +30 -0
- package/dist/jsonView/viewers.d.ts.map +1 -0
- package/dist/jsonView/viewers.js +226 -0
- package/dist/jsonView/viewers.js.map +1 -0
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +24 -10
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -3
- package/src/InlineAudioPlayer.tsx +46 -0
- package/src/InlineVideoPlayer.tsx +70 -0
- package/src/LinearDocView.tsx +136 -14
- package/src/MarkdownRenderer.tsx +156 -10
- package/src/SocialCaptionOverlay.tsx +3 -2
- package/src/__tests__/JsonView.test.tsx +111 -0
- package/src/__tests__/LinearDocView.test.tsx +66 -0
- package/src/hooks/MediaContext.tsx +15 -8
- package/src/index.ts +9 -0
- package/src/jsonView/JsonView.tsx +51 -0
- package/src/jsonView/RenderNode.tsx +51 -0
- package/src/jsonView/index.ts +2 -0
- package/src/jsonView/json-view.css +206 -0
- package/src/jsonView/useJsonViewTokens.ts +57 -0
- package/src/jsonView/viewers.tsx +343 -0
- package/src/styles/doc-animations.css +36 -0
- package/src/styles/index.css +7 -0
- package/src/types.ts +5 -1
package/dist/types.d.ts
CHANGED
|
@@ -26,8 +26,12 @@ export type ControlsLayout = 'overlay' | 'sidebar' | 'bottom';
|
|
|
26
26
|
* - `'linear'` — Long-scrolling document view. Renders the full markdown as
|
|
27
27
|
* readable HTML with template-annotated sections shown as inline SVG cards.
|
|
28
28
|
* No audio, no timeline, no controls — just a scrollable page.
|
|
29
|
+
* - `'page'` — Plain semantic HTML preview matching what the
|
|
30
|
+
* `markdownDocToPlainHtml` export produces. No SquisqPlayer, no SVG
|
|
31
|
+
* cards — just `<h1>`/`<p>`/`<ul>` etc. inside a sandboxed iframe.
|
|
32
|
+
* Use when you want a WYSIWYG view of the simple HTML export.
|
|
29
33
|
*/
|
|
30
|
-
export type DisplayMode = 'video' | 'slideshow' | 'linear';
|
|
34
|
+
export type DisplayMode = 'video' | 'slideshow' | 'linear' | 'page';
|
|
31
35
|
/**
|
|
32
36
|
* Caption display style.
|
|
33
37
|
*
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,2BAA2B,CAAC;AAEvD,0CAA0C;AAC1C,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE9D
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AA8JH,6CAA6C;AAC7C,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;AACvD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "React component library for doc playback, block rendering, and media layers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"types": "./dist/index.d.ts",
|
|
35
35
|
"import": "./dist/index.js"
|
|
36
36
|
},
|
|
37
|
-
"./styles": "./src/styles/
|
|
37
|
+
"./styles": "./src/styles/index.css",
|
|
38
38
|
"./standalone": "./dist/squisq-player.global.js",
|
|
39
39
|
"./standalone-source": {
|
|
40
40
|
"types": "./dist/standalone-source.d.ts",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
|
|
46
46
|
"build:esm": "tsup",
|
|
47
47
|
"build:standalone": "tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
|
|
48
|
+
"dev": "npm run build:standalone && concurrently -n js,dts -c blue,gray -r \"tsup --watch --no-dts --no-clean\" \"tsup --watch --dts-only --no-clean\"",
|
|
48
49
|
"typecheck": "tsc --noEmit"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
|
-
"@bendyline/squisq": "1.
|
|
56
|
+
"@bendyline/squisq": "1.4.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@types/react": "18.3.28",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineAudioPlayer
|
|
3
|
+
*
|
|
4
|
+
* Small inline HTML5 audio player for `<audio>` tags embedded directly in
|
|
5
|
+
* a markdown document — e.g. a narration recording the user wants to
|
|
6
|
+
* audition in the editor. Resolves the `src` through `MediaContext` so a
|
|
7
|
+
* container-backed `audio/foo.webm` path returns a playable blob URL.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately thin: native browser controls, no autoplay. The
|
|
10
|
+
* MarkdownRenderer swaps this in for raw `<audio>` htmlElements; the
|
|
11
|
+
* editor-react Tiptap audio node reuses it for its NodeView.
|
|
12
|
+
*/
|
|
13
|
+
import { useMediaUrl } from './hooks/MediaContext.js';
|
|
14
|
+
|
|
15
|
+
export interface InlineAudioPlayerProps {
|
|
16
|
+
/** Source path — resolved through MediaContext when relative. */
|
|
17
|
+
src: string;
|
|
18
|
+
/** Base path used when no MediaProvider is in context. */
|
|
19
|
+
basePath?: string;
|
|
20
|
+
/** Whether to show native controls. Defaults to true. */
|
|
21
|
+
controls?: boolean;
|
|
22
|
+
/** `preload` attribute passthrough. Defaults to `'metadata'`. */
|
|
23
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
24
|
+
/** Extra className on the wrapper. */
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function InlineAudioPlayer({
|
|
29
|
+
src,
|
|
30
|
+
basePath = '',
|
|
31
|
+
controls = true,
|
|
32
|
+
preload = 'metadata',
|
|
33
|
+
className,
|
|
34
|
+
}: InlineAudioPlayerProps) {
|
|
35
|
+
const resolvedSrc = useMediaUrl(src, basePath);
|
|
36
|
+
|
|
37
|
+
if (!resolvedSrc) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span className={`squisq-inline-audio-player ${className ?? ''}`.trim()}>
|
|
41
|
+
<audio src={resolvedSrc} controls={controls} preload={preload} />
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default InlineAudioPlayer;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineVideoPlayer
|
|
3
|
+
*
|
|
4
|
+
* Small inline HTML5 video player for `<video>` tags embedded directly in
|
|
5
|
+
* a markdown document — e.g. a recording dropped into the doc by the
|
|
6
|
+
* recorder. Resolves the `src` through `MediaContext` (same path
|
|
7
|
+
* `VideoLayer` uses) so a container-backed `audio/foo.webm` or
|
|
8
|
+
* `video/foo.mp4` resolves to a blob URL instead of 404-ing on the
|
|
9
|
+
* filesystem path.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately thin: native browser controls, intrinsic aspect ratio,
|
|
12
|
+
* no autoplay. The MarkdownRenderer swaps this in for a raw `<video>`
|
|
13
|
+
* htmlElement; the editor-react Tiptap video node uses the same
|
|
14
|
+
* component for its NodeView.
|
|
15
|
+
*/
|
|
16
|
+
import { useMediaUrl } from './hooks/MediaContext.js';
|
|
17
|
+
|
|
18
|
+
export interface InlineVideoPlayerProps {
|
|
19
|
+
/** Source path — resolved through MediaContext when relative. */
|
|
20
|
+
src: string;
|
|
21
|
+
/** Base path used when no MediaProvider is in context. */
|
|
22
|
+
basePath?: string;
|
|
23
|
+
/** Optional explicit width (pixels or CSS length). */
|
|
24
|
+
width?: number | string;
|
|
25
|
+
/** Optional explicit height (pixels or CSS length). */
|
|
26
|
+
height?: number | string;
|
|
27
|
+
/** Optional poster image src — also resolved through MediaContext. */
|
|
28
|
+
poster?: string;
|
|
29
|
+
/** Whether to show native controls. Defaults to true. */
|
|
30
|
+
controls?: boolean;
|
|
31
|
+
/** `preload` attribute passthrough. Defaults to `'metadata'`. */
|
|
32
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
33
|
+
/** Extra className on the wrapper. */
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function InlineVideoPlayer({
|
|
38
|
+
src,
|
|
39
|
+
basePath = '',
|
|
40
|
+
width,
|
|
41
|
+
height,
|
|
42
|
+
poster,
|
|
43
|
+
controls = true,
|
|
44
|
+
preload = 'metadata',
|
|
45
|
+
className,
|
|
46
|
+
}: InlineVideoPlayerProps) {
|
|
47
|
+
const resolvedSrc = useMediaUrl(src, basePath);
|
|
48
|
+
// Always call the hook (Rules of Hooks); only use the result if a poster
|
|
49
|
+
// was actually requested. Empty input → fallback path which we discard.
|
|
50
|
+
const resolvedPoster = useMediaUrl(poster ?? '', basePath);
|
|
51
|
+
const posterUrl = poster ? resolvedPoster : undefined;
|
|
52
|
+
|
|
53
|
+
if (!resolvedSrc) return null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<span className={`squisq-inline-video-player ${className ?? ''}`.trim()}>
|
|
57
|
+
<video
|
|
58
|
+
src={resolvedSrc}
|
|
59
|
+
poster={posterUrl}
|
|
60
|
+
controls={controls}
|
|
61
|
+
preload={preload}
|
|
62
|
+
width={width}
|
|
63
|
+
height={height}
|
|
64
|
+
playsInline
|
|
65
|
+
/>
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default InlineVideoPlayer;
|
package/src/LinearDocView.tsx
CHANGED
|
@@ -21,7 +21,12 @@ import { useMemo } from 'react';
|
|
|
21
21
|
import { useAutoSurface } from './hooks/useAutoSurface';
|
|
22
22
|
import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
|
|
23
23
|
import type { ViewportConfig } from '@bendyline/squisq/schemas';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
applySurface,
|
|
26
|
+
resolveFontFamily,
|
|
27
|
+
type SurfaceScheme,
|
|
28
|
+
type Theme,
|
|
29
|
+
} from '@bendyline/squisq/schemas';
|
|
25
30
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
26
31
|
import { getLayers, hasTemplate, DEFAULT_THEME } from '@bendyline/squisq/doc';
|
|
27
32
|
import type { RenderContext } from '@bendyline/squisq/doc';
|
|
@@ -164,23 +169,21 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
|
|
|
164
169
|
{/* Render the heading (if present — preamble has no sourceHeading) */}
|
|
165
170
|
{block.sourceHeading && !isAnnotated && <MarkdownRenderer nodes={[block.sourceHeading]} />}
|
|
166
171
|
|
|
167
|
-
{/* Annotated block: render SVG card
|
|
172
|
+
{/* Annotated block: render SVG card.
|
|
173
|
+
The heading is intentionally *not* duplicated above the card —
|
|
174
|
+
every template card renders its own title layer internally, so
|
|
175
|
+
a separate `squisq-linear-card-label` only made the heading
|
|
176
|
+
appear twice in the linear view. The card also drops its
|
|
177
|
+
rounded border + drop shadow so it reads as a continuation of
|
|
178
|
+
the surrounding page rather than a chrome'd preview. */}
|
|
168
179
|
{isAnnotated && visualBlock && (
|
|
169
180
|
<div className="squisq-linear-card">
|
|
170
|
-
{/* Optional heading label above the card */}
|
|
171
|
-
{block.sourceHeading && (
|
|
172
|
-
<div className="squisq-linear-card-label squisq-md">
|
|
173
|
-
<MarkdownRenderer nodes={[block.sourceHeading]} />
|
|
174
|
-
</div>
|
|
175
|
-
)}
|
|
176
181
|
<div
|
|
177
182
|
className="squisq-linear-card-svg"
|
|
178
183
|
style={{
|
|
179
184
|
width: '100%',
|
|
180
185
|
aspectRatio: `${viewport.width} / ${viewport.height}`,
|
|
181
186
|
overflow: 'hidden',
|
|
182
|
-
borderRadius: '8px',
|
|
183
|
-
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.12)',
|
|
184
187
|
marginBottom: '1em',
|
|
185
188
|
}}
|
|
186
189
|
>
|
|
@@ -244,6 +247,83 @@ function extractListItems(contents?: MarkdownBlockNode[]): string[] {
|
|
|
244
247
|
return items;
|
|
245
248
|
}
|
|
246
249
|
|
|
250
|
+
/** First image discovered in a block's body, with any explicit
|
|
251
|
+
* dimensions captured (set by `<img width>` / `<img height>` in raw
|
|
252
|
+
* HTML — markdown shorthand has no syntax for dimensions). */
|
|
253
|
+
interface FirstImage {
|
|
254
|
+
src: string;
|
|
255
|
+
alt: string;
|
|
256
|
+
width?: number;
|
|
257
|
+
height?: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Find the first image referenced anywhere in block contents — both
|
|
262
|
+
* markdown shorthand `` (type `image`) and raw HTML `<img>`
|
|
263
|
+
* tags (type `htmlBlock`/`htmlInline`). The WYSIWYG editor emits the
|
|
264
|
+
* HTML form whenever a user resizes an image (markdown shorthand has no
|
|
265
|
+
* width syntax), so missing that path silently breaks every resized
|
|
266
|
+
* image in the linear view.
|
|
267
|
+
*/
|
|
268
|
+
function extractFirstImage(contents: MarkdownBlockNode[] | undefined): FirstImage | null {
|
|
269
|
+
if (!contents || contents.length === 0) return null;
|
|
270
|
+
|
|
271
|
+
function fromHtml(nodes: unknown[]): FirstImage | null {
|
|
272
|
+
for (const node of nodes) {
|
|
273
|
+
if (!node || typeof node !== 'object') continue;
|
|
274
|
+
const n = node as Record<string, unknown>;
|
|
275
|
+
if (n.type === 'htmlElement' && n.tagName === 'img') {
|
|
276
|
+
const attrs = n.attributes as Record<string, string> | undefined;
|
|
277
|
+
if (attrs && typeof attrs.src === 'string' && attrs.src) {
|
|
278
|
+
return {
|
|
279
|
+
src: attrs.src,
|
|
280
|
+
alt: typeof attrs.alt === 'string' ? attrs.alt : '',
|
|
281
|
+
width: parseDim(attrs.width),
|
|
282
|
+
height: parseDim(attrs.height),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (Array.isArray(n.children)) {
|
|
287
|
+
const found = fromHtml(n.children);
|
|
288
|
+
if (found) return found;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function walk(node: unknown): FirstImage | null {
|
|
295
|
+
if (!node || typeof node !== 'object') return null;
|
|
296
|
+
const n = node as Record<string, unknown>;
|
|
297
|
+
if (n.type === 'image' && typeof n.url === 'string' && n.url) {
|
|
298
|
+
return { src: n.url, alt: typeof n.alt === 'string' ? n.alt : '' };
|
|
299
|
+
}
|
|
300
|
+
if ((n.type === 'htmlBlock' || n.type === 'htmlInline') && Array.isArray(n.htmlChildren)) {
|
|
301
|
+
const found = fromHtml(n.htmlChildren);
|
|
302
|
+
if (found) return found;
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(n.children)) {
|
|
305
|
+
for (const child of n.children) {
|
|
306
|
+
const found = walk(child);
|
|
307
|
+
if (found) return found;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const node of contents) {
|
|
314
|
+
const found = walk(node);
|
|
315
|
+
if (found) return found;
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Parse a `width`/`height` HTML attribute to a positive number. */
|
|
321
|
+
function parseDim(raw: string | undefined): number | undefined {
|
|
322
|
+
if (raw === undefined) return undefined;
|
|
323
|
+
const n = parseFloat(raw);
|
|
324
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
247
327
|
/** Extract table data (headers, rows, alignment) from block contents. */
|
|
248
328
|
function extractTableData(contents?: MarkdownBlockNode[]): {
|
|
249
329
|
headers: string[];
|
|
@@ -277,7 +357,7 @@ function getTemplateDefaults(
|
|
|
277
357
|
switch (templateName) {
|
|
278
358
|
case 'statHighlight':
|
|
279
359
|
return { stat: headingText, description: bodyText || headingText };
|
|
280
|
-
case '
|
|
360
|
+
case 'quote':
|
|
281
361
|
case 'fullBleedQuote':
|
|
282
362
|
case 'pullQuote':
|
|
283
363
|
return { quote: bodyText || headingText };
|
|
@@ -285,7 +365,7 @@ function getTemplateDefaults(
|
|
|
285
365
|
return { fact: headingText, explanation: bodyText || headingText };
|
|
286
366
|
case 'comparisonBar':
|
|
287
367
|
return { leftLabel: 'A', leftValue: 60, rightLabel: 'B', rightValue: 40 };
|
|
288
|
-
case '
|
|
368
|
+
case 'list': {
|
|
289
369
|
const items = extractListItems(contents);
|
|
290
370
|
return { items: items.length > 0 ? items : ['Item 1', 'Item 2', 'Item 3'] };
|
|
291
371
|
}
|
|
@@ -297,6 +377,37 @@ function getTemplateDefaults(
|
|
|
297
377
|
const tableData = extractTableData(contents);
|
|
298
378
|
return tableData ?? { headers: ['Column'], rows: [['Data']] };
|
|
299
379
|
}
|
|
380
|
+
case 'imageWithCaption': {
|
|
381
|
+
// The template requires `imageSrc` — without it the image layer
|
|
382
|
+
// renders a broken `<img src=undefined>`. Pull the first image
|
|
383
|
+
// out of the block's body (markdown shorthand or HTML <img>) and
|
|
384
|
+
// use the heading as a caption fallback so a bare `# Title
|
|
385
|
+
// [Image with Caption]` block renders something sensible.
|
|
386
|
+
const img = extractFirstImage(contents);
|
|
387
|
+
if (!img) return { caption: headingText };
|
|
388
|
+
return {
|
|
389
|
+
imageSrc: img.src,
|
|
390
|
+
imageAlt: img.alt || headingText,
|
|
391
|
+
caption: headingText,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
case 'leftFeature':
|
|
395
|
+
case 'rightFeature': {
|
|
396
|
+
// Feature blocks pair an image with the heading + the first
|
|
397
|
+
// paragraph of body text. Both inputs come from the section's
|
|
398
|
+
// contents — the image via the same scan used by imageWithCaption,
|
|
399
|
+
// the body text via the plain-text extractor that's already in
|
|
400
|
+
// scope here.
|
|
401
|
+
const img = extractFirstImage(contents);
|
|
402
|
+
return {
|
|
403
|
+
imageSrc: img?.src ?? '',
|
|
404
|
+
imageAlt: img?.alt || headingText,
|
|
405
|
+
imageWidth: img?.width,
|
|
406
|
+
imageHeight: img?.height,
|
|
407
|
+
title: headingText,
|
|
408
|
+
body: bodyText,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
300
411
|
default:
|
|
301
412
|
return {};
|
|
302
413
|
}
|
|
@@ -345,8 +456,8 @@ export function LinearDocView({
|
|
|
345
456
|
const textColor = activeTheme.colors.text;
|
|
346
457
|
const mutedColor = activeTheme.colors.textMuted;
|
|
347
458
|
const primaryColor = activeTheme.colors.primary;
|
|
348
|
-
const bodyFont = activeTheme.typography.
|
|
349
|
-
const titleFont = activeTheme.typography.
|
|
459
|
+
const bodyFont = resolveFontFamily(activeTheme.typography.bodyFont, 'system-ui, sans-serif');
|
|
460
|
+
const titleFont = resolveFontFamily(activeTheme.typography.titleFont, 'Georgia, serif');
|
|
350
461
|
const lineHt = activeTheme.typography.lineHeight ?? 1.7;
|
|
351
462
|
|
|
352
463
|
return (
|
|
@@ -418,6 +529,17 @@ export function LinearDocView({
|
|
|
418
529
|
margin-bottom: 0.3em;
|
|
419
530
|
}
|
|
420
531
|
.squisq-linear-content a {
|
|
532
|
+
/* Blend the theme's primary toward the body text color so
|
|
533
|
+
links stay theme-flavored but always have enough contrast
|
|
534
|
+
against the background. Some themes (e.g. Gezellig) pick a
|
|
535
|
+
mid-tone primary that's nearly invisible on a dark page
|
|
536
|
+
without this lift. */
|
|
537
|
+
color: color-mix(in srgb, var(--squisq-linear-primary) 65%, var(--squisq-linear-text));
|
|
538
|
+
text-decoration: underline;
|
|
539
|
+
text-decoration-thickness: 1px;
|
|
540
|
+
text-underline-offset: 2px;
|
|
541
|
+
}
|
|
542
|
+
.squisq-linear-content a:hover {
|
|
421
543
|
color: var(--squisq-linear-primary);
|
|
422
544
|
}
|
|
423
545
|
.squisq-linear-content code {
|
package/src/MarkdownRenderer.tsx
CHANGED
|
@@ -21,8 +21,12 @@ import type {
|
|
|
21
21
|
MarkdownListItem,
|
|
22
22
|
MarkdownTableRow,
|
|
23
23
|
MarkdownTableCell,
|
|
24
|
+
HtmlNode,
|
|
25
|
+
HtmlElement,
|
|
24
26
|
} from '@bendyline/squisq/markdown';
|
|
25
27
|
import { useMediaUrl } from './hooks/MediaContext';
|
|
28
|
+
import { InlineVideoPlayer } from './InlineVideoPlayer.js';
|
|
29
|
+
import { InlineAudioPlayer } from './InlineAudioPlayer.js';
|
|
26
30
|
|
|
27
31
|
// ── Props ──────────────────────────────────────────────────────────
|
|
28
32
|
|
|
@@ -118,12 +122,23 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
118
122
|
);
|
|
119
123
|
|
|
120
124
|
case 'htmlInline':
|
|
125
|
+
// Fast path: no <video>/<audio> in the subtree → use the original
|
|
126
|
+
// rawHtml passthrough (preserves arbitrary HTML for custom embeds).
|
|
127
|
+
if (!containsMediaTag(node.htmlChildren)) {
|
|
128
|
+
return (
|
|
129
|
+
<span
|
|
130
|
+
key={key}
|
|
131
|
+
className="squisq-md-html-inline"
|
|
132
|
+
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
// Otherwise reconstruct the subtree as React so <video>/<audio>
|
|
137
|
+
// go through MediaContext-aware player components.
|
|
121
138
|
return (
|
|
122
|
-
<span
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
126
|
-
/>
|
|
139
|
+
<span key={key} className="squisq-md-html-inline">
|
|
140
|
+
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
141
|
+
</span>
|
|
127
142
|
);
|
|
128
143
|
|
|
129
144
|
case 'footnoteReference':
|
|
@@ -231,12 +246,23 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
231
246
|
return renderTable(node.children, node.align, key);
|
|
232
247
|
|
|
233
248
|
case 'htmlBlock':
|
|
249
|
+
// Fast path: no <video>/<audio> → preserve the existing rawHtml
|
|
250
|
+
// passthrough so arbitrary HTML embeds still survive verbatim.
|
|
251
|
+
if (!containsMediaTag(node.htmlChildren)) {
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
key={key}
|
|
255
|
+
className="squisq-md-html-block"
|
|
256
|
+
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
// Otherwise reconstruct subtree as React so <video>/<audio>
|
|
261
|
+
// route through the player components and resolve via MediaContext.
|
|
234
262
|
return (
|
|
235
|
-
<div
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
239
|
-
/>
|
|
263
|
+
<div key={key} className="squisq-md-html-block">
|
|
264
|
+
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
265
|
+
</div>
|
|
240
266
|
);
|
|
241
267
|
|
|
242
268
|
case 'math':
|
|
@@ -377,6 +403,126 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
|
|
|
377
403
|
return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
|
|
378
404
|
}
|
|
379
405
|
|
|
406
|
+
// ── Raw-HTML walker (intercepts <video>/<audio>) ─────────────────
|
|
407
|
+
|
|
408
|
+
/** True when the htmlElement subtree contains a tag we want to swap
|
|
409
|
+
* for a React component. Cheap recursive scan — lets us keep the
|
|
410
|
+
* `dangerouslySetInnerHTML` fast path for everything else. */
|
|
411
|
+
function containsMediaTag(nodes: HtmlNode[]): boolean {
|
|
412
|
+
for (const node of nodes) {
|
|
413
|
+
if (node.type !== 'htmlElement') continue;
|
|
414
|
+
if (node.tagName === 'video' || node.tagName === 'audio') return true;
|
|
415
|
+
if (containsMediaTag(node.children)) return true;
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A pragmatic shortlist of HTML attributes the raw-HTML walker
|
|
421
|
+
* passes through to React when reconstructing a non-media element.
|
|
422
|
+
* Anything outside this list is silently dropped — the media-tag
|
|
423
|
+
* fast path means most authors will never hit this code, so we
|
|
424
|
+
* keep the surface narrow to avoid React warnings about unknown
|
|
425
|
+
* attributes. */
|
|
426
|
+
const PASSTHROUGH_ATTRS: Record<string, string> = {
|
|
427
|
+
// common
|
|
428
|
+
class: 'className',
|
|
429
|
+
id: 'id',
|
|
430
|
+
title: 'title',
|
|
431
|
+
style: 'style',
|
|
432
|
+
// media-adjacent (used when video/audio appear inside other wrappers)
|
|
433
|
+
width: 'width',
|
|
434
|
+
height: 'height',
|
|
435
|
+
// anchor
|
|
436
|
+
href: 'href',
|
|
437
|
+
target: 'target',
|
|
438
|
+
rel: 'rel',
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
function reactPropsFromAttrs(attrs: Record<string, string>): Record<string, unknown> {
|
|
442
|
+
const out: Record<string, unknown> = {};
|
|
443
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
444
|
+
const propName = PASSTHROUGH_ATTRS[name];
|
|
445
|
+
if (!propName) continue;
|
|
446
|
+
if (propName === 'style') {
|
|
447
|
+
// We can't safely parse arbitrary `style="..."` strings without
|
|
448
|
+
// an HTML parser; preserve as a data attribute so authors can
|
|
449
|
+
// see it survived round-trip without crashing React.
|
|
450
|
+
out['data-style'] = value;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
out[propName] = value;
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderHtmlElement(el: HtmlElement, key: string): React.ReactNode {
|
|
459
|
+
if (el.tagName === 'video') {
|
|
460
|
+
return (
|
|
461
|
+
<InlineVideoPlayer
|
|
462
|
+
key={key}
|
|
463
|
+
src={el.attributes.src ?? ''}
|
|
464
|
+
width={el.attributes.width}
|
|
465
|
+
height={el.attributes.height}
|
|
466
|
+
poster={el.attributes.poster}
|
|
467
|
+
// The `controls` attribute is a boolean — present means true,
|
|
468
|
+
// even if its value is an empty string.
|
|
469
|
+
controls={'controls' in el.attributes}
|
|
470
|
+
preload={
|
|
471
|
+
el.attributes.preload === 'none' ||
|
|
472
|
+
el.attributes.preload === 'metadata' ||
|
|
473
|
+
el.attributes.preload === 'auto'
|
|
474
|
+
? el.attributes.preload
|
|
475
|
+
: undefined
|
|
476
|
+
}
|
|
477
|
+
/>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (el.tagName === 'audio') {
|
|
481
|
+
return (
|
|
482
|
+
<InlineAudioPlayer
|
|
483
|
+
key={key}
|
|
484
|
+
src={el.attributes.src ?? ''}
|
|
485
|
+
controls={'controls' in el.attributes}
|
|
486
|
+
preload={
|
|
487
|
+
el.attributes.preload === 'none' ||
|
|
488
|
+
el.attributes.preload === 'metadata' ||
|
|
489
|
+
el.attributes.preload === 'auto'
|
|
490
|
+
? el.attributes.preload
|
|
491
|
+
: undefined
|
|
492
|
+
}
|
|
493
|
+
/>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const Tag = el.tagName as keyof JSX.IntrinsicElements;
|
|
498
|
+
const props = reactPropsFromAttrs(el.attributes);
|
|
499
|
+
if (el.selfClosing) {
|
|
500
|
+
return <Tag key={key} {...props} />;
|
|
501
|
+
}
|
|
502
|
+
return (
|
|
503
|
+
<Tag key={key} {...props}>
|
|
504
|
+
{renderHtmlNodes(el.children, `${key}c`)}
|
|
505
|
+
</Tag>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderHtmlNodes(nodes: HtmlNode[], keyPrefix: string): React.ReactNode[] {
|
|
510
|
+
return nodes.map((node, i) => {
|
|
511
|
+
const key = `${keyPrefix}${i}`;
|
|
512
|
+
switch (node.type) {
|
|
513
|
+
case 'htmlElement':
|
|
514
|
+
return renderHtmlElement(node, key);
|
|
515
|
+
case 'htmlText':
|
|
516
|
+
return <Fragment key={key}>{node.value}</Fragment>;
|
|
517
|
+
case 'htmlComment':
|
|
518
|
+
// Comments don't render in React; ignore.
|
|
519
|
+
return null;
|
|
520
|
+
default:
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
380
526
|
// ── Main Component ─────────────────────────────────────────────────
|
|
381
527
|
|
|
382
528
|
/**
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { useMemo } from 'react';
|
|
18
18
|
import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
|
|
19
19
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
20
|
+
import { resolveFontFamily } from '@bendyline/squisq/schemas';
|
|
20
21
|
|
|
21
22
|
/** Target words per visible chunk. */
|
|
22
23
|
const TARGET_CHUNK_SIZE = 4;
|
|
@@ -196,8 +197,8 @@ export function SocialCaptionOverlay({
|
|
|
196
197
|
|
|
197
198
|
// Theme-derived styling
|
|
198
199
|
const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
|
|
199
|
-
const fontFamily = theme?.typography?.
|
|
200
|
-
?
|
|
200
|
+
const fontFamily = theme?.typography?.titleFont
|
|
201
|
+
? resolveFontFamily(theme.typography.titleFont, '"PT Serif", Georgia, serif')
|
|
201
202
|
: '"PT Serif", Georgia, serif';
|
|
202
203
|
|
|
203
204
|
// Scale font to viewport — aim for ~5.5% of viewport height
|