@bendyline/squisq-react 1.1.2 → 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/DocPlayer.d.ts +9 -2
- package/dist/DocPlayer.d.ts.map +1 -1
- package/dist/DocPlayer.js +27 -8
- package/dist/DocPlayer.js.map +1 -1
- 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 +28 -2
- package/dist/LinearDocView.d.ts.map +1 -1
- package/dist/LinearDocView.js +159 -24
- package/dist/LinearDocView.js.map +1 -1
- package/dist/MarkdownRenderer.d.ts.map +1 -1
- package/dist/MarkdownRenderer.js +123 -4
- 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 +90 -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/hooks/useAutoSurface.d.ts +11 -0
- package/dist/hooks/useAutoSurface.d.ts.map +1 -0
- package/dist/hooks/useAutoSurface.js +24 -0
- package/dist/hooks/useAutoSurface.js.map +1 -0
- package/dist/index.d.ts +8 -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 +32 -8
- 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 +8 -7
- package/src/DocPlayer.tsx +44 -8
- package/src/InlineAudioPlayer.tsx +46 -0
- package/src/InlineVideoPlayer.tsx +70 -0
- package/src/LinearDocView.tsx +198 -26
- package/src/MarkdownRenderer.tsx +189 -12
- package/src/SocialCaptionOverlay.tsx +3 -2
- package/src/__tests__/JsonView.test.tsx +111 -0
- package/src/__tests__/LinearDocView.test.tsx +101 -0
- package/src/hooks/MediaContext.tsx +15 -8
- package/src/hooks/useAutoSurface.ts +33 -0
- package/src/index.ts +10 -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/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
|
|
|
@@ -40,8 +44,25 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
40
44
|
return nodes.map((node, i) => {
|
|
41
45
|
const key = `${keyPrefix}i${i}`;
|
|
42
46
|
switch (node.type) {
|
|
43
|
-
case 'text':
|
|
44
|
-
|
|
47
|
+
case 'text': {
|
|
48
|
+
// Soft breaks (newlines without two trailing spaces) become \n in
|
|
49
|
+
// text nodes. HTML collapses \n to whitespace, which loses the visual
|
|
50
|
+
// line break the author wanted, so render <br> for each newline.
|
|
51
|
+
if (!node.value.includes('\n')) {
|
|
52
|
+
return <Fragment key={key}>{node.value}</Fragment>;
|
|
53
|
+
}
|
|
54
|
+
const parts = node.value.split('\n');
|
|
55
|
+
return (
|
|
56
|
+
<Fragment key={key}>
|
|
57
|
+
{parts.map((part, j) => (
|
|
58
|
+
<Fragment key={j}>
|
|
59
|
+
{j > 0 && <br />}
|
|
60
|
+
{part}
|
|
61
|
+
</Fragment>
|
|
62
|
+
))}
|
|
63
|
+
</Fragment>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
45
66
|
|
|
46
67
|
case 'emphasis':
|
|
47
68
|
return (
|
|
@@ -101,12 +122,23 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
101
122
|
);
|
|
102
123
|
|
|
103
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.
|
|
104
138
|
return (
|
|
105
|
-
<span
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
109
|
-
/>
|
|
139
|
+
<span key={key} className="squisq-md-html-inline">
|
|
140
|
+
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
141
|
+
</span>
|
|
110
142
|
);
|
|
111
143
|
|
|
112
144
|
case 'footnoteReference':
|
|
@@ -138,6 +170,20 @@ function renderInline(nodes: MarkdownInlineNode[], keyPrefix = ''): React.ReactN
|
|
|
138
170
|
</span>
|
|
139
171
|
);
|
|
140
172
|
|
|
173
|
+
case 'mention':
|
|
174
|
+
return (
|
|
175
|
+
<span
|
|
176
|
+
key={key}
|
|
177
|
+
className="squisq-md-mention mention"
|
|
178
|
+
data-mention="true"
|
|
179
|
+
data-kind={node.targetKind}
|
|
180
|
+
data-id={node.targetId}
|
|
181
|
+
data-label={node.displayName}
|
|
182
|
+
>
|
|
183
|
+
@{node.displayName}
|
|
184
|
+
</span>
|
|
185
|
+
);
|
|
186
|
+
|
|
141
187
|
default:
|
|
142
188
|
return null;
|
|
143
189
|
}
|
|
@@ -200,12 +246,23 @@ function renderBlock(node: MarkdownBlockNode, key: string): React.ReactNode {
|
|
|
200
246
|
return renderTable(node.children, node.align, key);
|
|
201
247
|
|
|
202
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.
|
|
203
262
|
return (
|
|
204
|
-
<div
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
dangerouslySetInnerHTML={{ __html: node.rawHtml }}
|
|
208
|
-
/>
|
|
263
|
+
<div key={key} className="squisq-md-html-block">
|
|
264
|
+
{renderHtmlNodes(node.htmlChildren, `${key}h`)}
|
|
265
|
+
</div>
|
|
209
266
|
);
|
|
210
267
|
|
|
211
268
|
case 'math':
|
|
@@ -346,6 +403,126 @@ function MdImage({ src, alt, title }: { src: string; alt: string; title?: string
|
|
|
346
403
|
return <img className="squisq-md-image" src={resolved} alt={alt} title={title} />;
|
|
347
404
|
}
|
|
348
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
|
+
|
|
349
526
|
// ── Main Component ─────────────────────────────────────────────────
|
|
350
527
|
|
|
351
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
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { JsonView } from '../jsonView';
|
|
4
|
+
import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
|
|
5
|
+
|
|
6
|
+
describe('JsonView', () => {
|
|
7
|
+
it('renders an object schema as labeled rows', () => {
|
|
8
|
+
const schema: SquisqAnnotatedSchema = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
title: { type: 'string', title: 'Page Title' },
|
|
12
|
+
active: { type: 'boolean' },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
const { container } = render(
|
|
16
|
+
<JsonView schema={schema} value={{ title: 'Hello', active: true }} />,
|
|
17
|
+
);
|
|
18
|
+
expect(container.textContent).toContain('Page Title');
|
|
19
|
+
expect(container.textContent).toContain('Hello');
|
|
20
|
+
expect(container.textContent).toContain('On');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders an array of primitives as chips', () => {
|
|
24
|
+
const schema: SquisqAnnotatedSchema = {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: { type: 'string' },
|
|
27
|
+
};
|
|
28
|
+
const { container } = render(<JsonView schema={schema} value={['a', 'b', 'c']} />);
|
|
29
|
+
expect(container.querySelectorAll('.squisq-jv-chip')).toHaveLength(3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders an array of objects as cards with itemLabel.fromField', () => {
|
|
33
|
+
const schema: SquisqAnnotatedSchema = {
|
|
34
|
+
type: 'array',
|
|
35
|
+
items: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
heading: { type: 'string' },
|
|
39
|
+
body: { type: 'string' },
|
|
40
|
+
},
|
|
41
|
+
squisq: { itemLabel: { fromField: 'heading' } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<JsonView
|
|
46
|
+
schema={schema}
|
|
47
|
+
value={[
|
|
48
|
+
{ heading: 'Section A', body: 'Body A' },
|
|
49
|
+
{ heading: 'Section B', body: 'Body B' },
|
|
50
|
+
]}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
const cards = container.querySelectorAll('.squisq-jv-card');
|
|
54
|
+
expect(cards).toHaveLength(2);
|
|
55
|
+
expect(cards[0].textContent).toContain('Section A');
|
|
56
|
+
expect(cards[1].textContent).toContain('Section B');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders a color value as a swatch + hex', () => {
|
|
60
|
+
const schema: SquisqAnnotatedSchema = {
|
|
61
|
+
type: 'string',
|
|
62
|
+
format: 'color',
|
|
63
|
+
};
|
|
64
|
+
const { container } = render(<JsonView schema={schema} value="#ff0080" />);
|
|
65
|
+
const swatch = container.querySelector('.squisq-jv-color__swatch') as HTMLElement | null;
|
|
66
|
+
expect(swatch).not.toBeNull();
|
|
67
|
+
expect(swatch?.style.background).toContain('rgb(255, 0, 128)');
|
|
68
|
+
expect(container.textContent).toContain('#ff0080');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('hides fields whose squisq.hidden rule matches', () => {
|
|
72
|
+
const schema: SquisqAnnotatedSchema = {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
showAuthor: { type: 'boolean' },
|
|
76
|
+
authorName: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
squisq: { hidden: { field: 'showAuthor', truthy: false } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const visible = render(
|
|
83
|
+
<JsonView schema={schema} value={{ showAuthor: true, authorName: 'Alex' }} />,
|
|
84
|
+
);
|
|
85
|
+
expect(visible.container.textContent).toContain('Alex');
|
|
86
|
+
|
|
87
|
+
const hidden = render(
|
|
88
|
+
<JsonView schema={schema} value={{ showAuthor: false, authorName: 'Alex' }} />,
|
|
89
|
+
);
|
|
90
|
+
expect(hidden.container.textContent).not.toContain('Alex');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('renders empty values as the em-dash placeholder', () => {
|
|
94
|
+
const schema: SquisqAnnotatedSchema = {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { title: { type: 'string' } },
|
|
97
|
+
};
|
|
98
|
+
const { container } = render(<JsonView schema={schema} value={{}} />);
|
|
99
|
+
expect(container.textContent).toContain('—');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('respects squisq.enumLabels when displaying enum values', () => {
|
|
103
|
+
const schema: SquisqAnnotatedSchema = {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: ['s', 'm', 'l'],
|
|
106
|
+
squisq: { enumLabels: { s: 'Small', m: 'Medium', l: 'Large' } },
|
|
107
|
+
};
|
|
108
|
+
const { container } = render(<JsonView schema={schema} value="m" />);
|
|
109
|
+
expect(container.textContent).toContain('Medium');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { render } from '@testing-library/react';
|
|
3
3
|
import { LinearDocView } from '../LinearDocView';
|
|
4
4
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
5
|
+
import { DARK_SURFACE, DEFAULT_THEME, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
|
|
5
6
|
import type {
|
|
6
7
|
MarkdownBlockNode,
|
|
7
8
|
MarkdownInlineNode,
|
|
@@ -84,6 +85,72 @@ describe('LinearDocView', () => {
|
|
|
84
85
|
expect(container.textContent).toContain('Section body text');
|
|
85
86
|
});
|
|
86
87
|
|
|
88
|
+
it('feeds imageWithCaption blocks the first body image as imageSrc', () => {
|
|
89
|
+
// Regression: without auto-extracting imageSrc from contents, the
|
|
90
|
+
// template renders a layer with `src=undefined` and the SVG card
|
|
91
|
+
// shows a broken image in document (linear) mode.
|
|
92
|
+
const doc = mkDoc([
|
|
93
|
+
mkBlock({
|
|
94
|
+
id: 'img-1',
|
|
95
|
+
sourceHeading: {
|
|
96
|
+
type: 'heading',
|
|
97
|
+
depth: 1,
|
|
98
|
+
children: [text('Mike Ammerlaan')],
|
|
99
|
+
templateAnnotation: { template: 'imageWithCaption' },
|
|
100
|
+
},
|
|
101
|
+
contents: [paragraph({ type: 'image', url: 'mikehome_files/profile.png', alt: 'Mike' })],
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
105
|
+
const card = container.querySelector('.squisq-linear-card');
|
|
106
|
+
expect(card).toBeTruthy();
|
|
107
|
+
const img = card!.querySelector('image, img') as Element | null;
|
|
108
|
+
expect(img).toBeTruthy();
|
|
109
|
+
const href =
|
|
110
|
+
img!.getAttribute('href') ?? img!.getAttribute('xlink:href') ?? img!.getAttribute('src');
|
|
111
|
+
expect(href).toContain('mikehome_files/profile.png');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('extracts imageSrc from raw HTML <img> (resized image)', () => {
|
|
115
|
+
// The WYSIWYG editor emits `<img src width>` for resized images
|
|
116
|
+
// because markdown shorthand has no width syntax. The linear view
|
|
117
|
+
// must read that form too, or every resized imageWithCaption block
|
|
118
|
+
// renders as a broken card.
|
|
119
|
+
const doc = mkDoc([
|
|
120
|
+
mkBlock({
|
|
121
|
+
id: 'img-2',
|
|
122
|
+
sourceHeading: {
|
|
123
|
+
type: 'heading',
|
|
124
|
+
depth: 1,
|
|
125
|
+
children: [text('Resized')],
|
|
126
|
+
templateAnnotation: { template: 'imageWithCaption' },
|
|
127
|
+
},
|
|
128
|
+
contents: [
|
|
129
|
+
{
|
|
130
|
+
type: 'htmlBlock',
|
|
131
|
+
rawHtml: '<img alt="resized" src="resized.png" width="194">',
|
|
132
|
+
htmlChildren: [
|
|
133
|
+
{
|
|
134
|
+
type: 'htmlElement',
|
|
135
|
+
tagName: 'img',
|
|
136
|
+
attributes: { src: 'resized.png', alt: 'resized', width: '194' },
|
|
137
|
+
children: [],
|
|
138
|
+
selfClosing: true,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
} as unknown as MarkdownBlockNode,
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
146
|
+
const card = container.querySelector('.squisq-linear-card');
|
|
147
|
+
expect(card).toBeTruthy();
|
|
148
|
+
const img = card!.querySelector('image, img') as Element | null;
|
|
149
|
+
const href =
|
|
150
|
+
img?.getAttribute('href') ?? img?.getAttribute('xlink:href') ?? img?.getAttribute('src');
|
|
151
|
+
expect(href).toContain('resized.png');
|
|
152
|
+
});
|
|
153
|
+
|
|
87
154
|
it('renders annotated block as SVG card', () => {
|
|
88
155
|
const doc = mkDoc([
|
|
89
156
|
mkBlock({
|
|
@@ -177,4 +244,38 @@ describe('LinearDocView', () => {
|
|
|
177
244
|
expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
|
|
178
245
|
expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
|
|
179
246
|
});
|
|
247
|
+
|
|
248
|
+
it('uses the theme background by default', () => {
|
|
249
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
250
|
+
const { container } = render(<LinearDocView doc={doc} />);
|
|
251
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
252
|
+
expect(el.style.background).toBeTruthy();
|
|
253
|
+
// DEFAULT_THEME has a specific background; applying LIGHT_SURFACE below
|
|
254
|
+
// must produce a different value to prove override is working.
|
|
255
|
+
expect(el.style.background).not.toBe(LIGHT_SURFACE.background);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('light surface overlays the theme background', () => {
|
|
259
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
260
|
+
const { container } = render(
|
|
261
|
+
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={LIGHT_SURFACE} />,
|
|
262
|
+
);
|
|
263
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
264
|
+
// React inline style background may round-trip as hex or rgb; compare the
|
|
265
|
+
// colour-normalized value by mounting a plain div with the expected.
|
|
266
|
+
const probe = document.createElement('div');
|
|
267
|
+
probe.style.background = LIGHT_SURFACE.background;
|
|
268
|
+
expect(el.style.background).toBe(probe.style.background);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('dark surface overlays the theme background', () => {
|
|
272
|
+
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('hi'))] })]);
|
|
273
|
+
const { container } = render(
|
|
274
|
+
<LinearDocView doc={doc} theme={DEFAULT_THEME} surface={DARK_SURFACE} />,
|
|
275
|
+
);
|
|
276
|
+
const el = container.querySelector('.squisq-linear') as HTMLElement;
|
|
277
|
+
const probe = document.createElement('div');
|
|
278
|
+
probe.style.background = DARK_SURFACE.background;
|
|
279
|
+
expect(el.style.background).toBe(probe.style.background);
|
|
280
|
+
});
|
|
180
281
|
});
|
|
@@ -41,17 +41,24 @@ export function useMediaProvider(): MediaProvider | null {
|
|
|
41
41
|
export function useMediaUrl(relativePath: string, basePath: string): string {
|
|
42
42
|
const provider = useMediaProvider();
|
|
43
43
|
|
|
44
|
+
// Defensive: callers (esp. preview surfaces like InlinePreviewGutter)
|
|
45
|
+
// sometimes feed in template-generated layers whose `content.src` is
|
|
46
|
+
// undefined while the user is still authoring the block. Treat that
|
|
47
|
+
// as an empty string rather than crashing the whole React tree.
|
|
48
|
+
const safePath = typeof relativePath === 'string' ? relativePath : '';
|
|
49
|
+
|
|
44
50
|
// For absolute/http URLs, skip resolution entirely
|
|
45
51
|
const isAbsolute =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
!safePath ||
|
|
53
|
+
safePath.startsWith('http') ||
|
|
54
|
+
safePath.startsWith('/') ||
|
|
55
|
+
safePath.startsWith('data:') ||
|
|
56
|
+
safePath.startsWith('blob:');
|
|
50
57
|
|
|
51
58
|
// Memoize fallback to avoid recalculating on every render
|
|
52
59
|
const fallback = useMemo(
|
|
53
|
-
() => (isAbsolute ?
|
|
54
|
-
[isAbsolute,
|
|
60
|
+
() => (isAbsolute ? safePath : `${basePath}/${safePath}`),
|
|
61
|
+
[isAbsolute, safePath, basePath],
|
|
55
62
|
);
|
|
56
63
|
|
|
57
64
|
// Fast path: no provider or absolute URL — return synchronously, skip effect entirely
|
|
@@ -66,14 +73,14 @@ export function useMediaUrl(relativePath: string, basePath: string): string {
|
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
let cancelled = false;
|
|
69
|
-
provider!.resolveUrl(
|
|
76
|
+
provider!.resolveUrl(safePath).then((resolved) => {
|
|
70
77
|
if (!cancelled) setUrl(resolved);
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
return () => {
|
|
74
81
|
cancelled = true;
|
|
75
82
|
};
|
|
76
|
-
}, [needsProvider, provider,
|
|
83
|
+
}, [needsProvider, provider, safePath, fallback]);
|
|
77
84
|
|
|
78
85
|
// When provider is not needed, return fallback directly to avoid
|
|
79
86
|
// the one-frame delay from the initial useState → useEffect cycle
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
+
import { DARK_SURFACE, LIGHT_SURFACE, type SurfaceScheme } from '@bendyline/squisq/schemas';
|
|
3
|
+
|
|
4
|
+
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
|
5
|
+
const getServerSnapshot = () => LIGHT_SURFACE;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Live-track `prefers-color-scheme` and return a stable SurfaceScheme.
|
|
9
|
+
* `enabled: false` short-circuits to LIGHT_SURFACE (callers pass `false`
|
|
10
|
+
* when a static surface was provided so the hook never observes the
|
|
11
|
+
* media query). The `MediaQueryList` and the `subscribe`/`getSnapshot`
|
|
12
|
+
* callbacks are memoized so `useSyncExternalStore` doesn't resubscribe on
|
|
13
|
+
* every parent render.
|
|
14
|
+
*/
|
|
15
|
+
export function useAutoSurface(enabled: boolean): SurfaceScheme {
|
|
16
|
+
const mql = useMemo(
|
|
17
|
+
() => (enabled && typeof window !== 'undefined' ? window.matchMedia(DARK_QUERY) : null),
|
|
18
|
+
[enabled],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const subscribe = useCallback(
|
|
22
|
+
(cb: () => void) => {
|
|
23
|
+
if (!mql) return () => {};
|
|
24
|
+
mql.addEventListener('change', cb);
|
|
25
|
+
return () => mql.removeEventListener('change', cb);
|
|
26
|
+
},
|
|
27
|
+
[mql],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const getSnapshot = useCallback(() => (mql?.matches ? DARK_SURFACE : LIGHT_SURFACE), [mql]);
|
|
31
|
+
|
|
32
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ export { DocPlayerWithSidebar } from './DocPlayerWithSidebar.js';
|
|
|
11
11
|
export { DocProgressBar } from './DocProgressBar.js';
|
|
12
12
|
export { MarkdownRenderer } from './MarkdownRenderer.js';
|
|
13
13
|
export { LinearDocView } from './LinearDocView.js';
|
|
14
|
+
export type { LinearDocViewProps, ImageDisplayMode } from './LinearDocView.js';
|
|
15
|
+
export { InlineVideoPlayer } from './InlineVideoPlayer.js';
|
|
16
|
+
export type { InlineVideoPlayerProps } from './InlineVideoPlayer.js';
|
|
17
|
+
export { InlineAudioPlayer } from './InlineAudioPlayer.js';
|
|
18
|
+
export type { InlineAudioPlayerProps } from './InlineAudioPlayer.js';
|
|
14
19
|
|
|
15
20
|
// Layer components
|
|
16
21
|
export { ImageLayer } from './layers/ImageLayer.js';
|
|
@@ -25,6 +30,7 @@ export { useAudioSync } from './hooks/useAudioSync.js';
|
|
|
25
30
|
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
26
31
|
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
27
32
|
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
33
|
+
export { useAutoSurface } from './hooks/useAutoSurface.js';
|
|
28
34
|
|
|
29
35
|
// Types
|
|
30
36
|
export type { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
|
|
@@ -48,3 +54,7 @@ export { formatTime } from './types.js';
|
|
|
48
54
|
|
|
49
55
|
// Utilities
|
|
50
56
|
export { getAnimationStyle, getTransitionClass } from './utils/animationUtils.js';
|
|
57
|
+
|
|
58
|
+
// JSON Form — read-only viewer
|
|
59
|
+
export { JsonView } from './jsonView/index.js';
|
|
60
|
+
export type { JsonViewProps } from './jsonView/index.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <JsonView>
|
|
3
|
+
*
|
|
4
|
+
* Read-only renderer for any JSON value bound to a JSON Schema (with
|
|
5
|
+
* optional Squisq UI hints). Designed to look like a polished settings
|
|
6
|
+
* summary or CRM record — not a disabled form. Themable via the
|
|
7
|
+
* standard Theme + SurfaceScheme props used elsewhere in Squisq.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SquisqAnnotatedSchema } from '@bendyline/squisq/jsonForm';
|
|
11
|
+
import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
|
|
12
|
+
import { useJsonViewTokens } from './useJsonViewTokens';
|
|
13
|
+
import { RenderNode } from './RenderNode';
|
|
14
|
+
|
|
15
|
+
export interface JsonViewProps {
|
|
16
|
+
/** Schema describing the value's shape (with optional `squisq` UI hints). */
|
|
17
|
+
schema: SquisqAnnotatedSchema;
|
|
18
|
+
/** The value to display. */
|
|
19
|
+
value: unknown;
|
|
20
|
+
/** Optional theme. Defaults to `DEFAULT_THEME`. */
|
|
21
|
+
theme?: Theme;
|
|
22
|
+
/** Light/dark surface override; `'auto'` follows `prefers-color-scheme`. */
|
|
23
|
+
surface?: SurfaceScheme | 'auto';
|
|
24
|
+
/** Padding/gap density. Default: 'comfortable'. */
|
|
25
|
+
density?: 'comfortable' | 'compact';
|
|
26
|
+
/** Optional CSS class for the outer container. */
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function JsonView(props: JsonViewProps) {
|
|
31
|
+
const { schema, value, theme, surface, density = 'comfortable', className } = props;
|
|
32
|
+
const { style } = useJsonViewTokens(theme, surface);
|
|
33
|
+
|
|
34
|
+
const cls =
|
|
35
|
+
'squisq-json-view' +
|
|
36
|
+
(density === 'compact' ? ' squisq-json-view--compact' : '') +
|
|
37
|
+
(className ? ` ${className}` : '');
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className={cls} style={style}>
|
|
41
|
+
<RenderNode
|
|
42
|
+
value={value}
|
|
43
|
+
schema={schema}
|
|
44
|
+
rootSchema={schema}
|
|
45
|
+
rootData={value}
|
|
46
|
+
pointer=""
|
|
47
|
+
density={density}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|