@drawnagency/primitives 0.1.118 → 0.1.120
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/{chunk-O37AV27P.js → chunk-5JYHTHVR.js} +28 -0
- package/dist/{chunk-W72BPGR3.js → chunk-BZKHHUIN.js} +44 -8
- package/dist/{chunk-4NSOKEA7.js → chunk-HFHM4MNK.js} +12 -7
- package/dist/{chunk-CEFFP3CH.js → chunk-HZTPF6OK.js} +304 -83
- package/dist/{chunk-RIITGTV3.js → chunk-LQHBC7C6.js} +37 -2
- package/dist/{chunk-VY3IFGE3.js → chunk-QAMAIDXZ.js} +1 -1
- package/dist/{chunk-HRX3XJBU.js → chunk-RIN2QCYJ.js} +9 -11
- package/dist/{chunk-ZOTRHHUB.js → chunk-UNFPATDE.js} +8 -10
- package/dist/{chunk-ERLIL4RU.js → chunk-UZBFVVTC.js} +35 -37
- package/dist/{chunk-67P34WNW.js → chunk-ZKVQMIMJ.js} +3 -20
- package/dist/collab/rebase.js +4 -5
- package/dist/components/chat/ChatComposer.d.ts +0 -4
- package/dist/components/chat/ChatComposer.d.ts.map +1 -1
- package/dist/components/chat/ChatPanel.d.ts +2 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/markdown.d.ts +2 -3
- package/dist/components/chat/markdown.d.ts.map +1 -1
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/IndicatorPill.d.ts +3 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts +0 -2
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/SectionBulkEditModal.d.ts +25 -0
- package/dist/components/editor/SectionBulkEditModal.d.ts.map +1 -0
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +28 -26
- package/dist/components/primitives/index.js +11 -15
- package/dist/components/sections/brand-guide.js +10 -13
- package/dist/components/sections/register-schemas.js +16 -21
- package/dist/components/sections/register.js +16 -21
- package/dist/components/shared/Popover.d.ts +1 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shared/SectionSummaryRow.d.ts +1 -1
- package/dist/components/shell/AudienceChips.d.ts +1 -3
- package/dist/components/shell/AudienceChips.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts +0 -34
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/hooks/useSortableNavRow.d.ts +1 -1
- package/dist/hooks/useSortableRow.d.ts +1 -1
- package/dist/index.js +83 -90
- package/dist/lib/index-ops.js +5 -6
- package/dist/lib/index.js +27 -32
- package/dist/lib/pages.d.ts +0 -2
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/sidecars.js +2 -2
- package/dist/lib/site-validation.js +13 -14
- package/dist/lib/storage-tier.js +4 -4
- package/dist/schemas/index.js +42 -45
- package/dist/schemas/site-config.d.ts +1 -277
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +2 -42
- package/src/components/chat/ChatComposer.tsx +0 -4
- package/src/components/chat/ChatPanel.tsx +2 -3
- package/src/components/chat/markdown.ts +2 -3
- package/src/components/editor/AudienceIndicator.tsx +8 -4
- package/src/components/editor/IndicatorPill.tsx +4 -0
- package/src/components/editor/PagesModal.tsx +1 -15
- package/src/components/editor/SectionBulkEditModal.tsx +328 -0
- package/src/components/editor/StatusIndicator.tsx +7 -6
- package/src/components/shared/Popover.tsx +17 -4
- package/src/components/shared/SectionSummaryRow.tsx +1 -1
- package/src/components/shell/AudienceChips.tsx +1 -3
- package/src/components/shell/EditorModal.tsx +5 -2
- package/src/components/shell/EditorShell.tsx +15 -119
- package/src/components/shell/SiteSettingsModal.tsx +0 -11
- package/src/lib/pages.ts +0 -22
- package/src/schemas/index.ts +0 -4
- package/src/schemas/site-config.ts +4 -32
- package/dist/chunk-4TZJZU2T.js +0 -92
- package/dist/chunk-DIMDRBFK.js +0 -11
- package/dist/chunk-FV7JQRCN.js +0 -94
- package/dist/chunk-IIDKW7AV.js +0 -41
- package/dist/chunk-JCDZAFX2.js +0 -224
- package/dist/chunk-MM7KZS5C.js +0 -30
- package/dist/chunk-QQ3S3P5P.js +0 -38
- package/dist/chunk-TEOAKGZA.js +0 -784
- package/dist/chunk-VAYZJKVR.js +0 -209
- package/dist/components/editor/SectionOrderingModal.d.ts +0 -10
- package/dist/components/editor/SectionOrderingModal.d.ts.map +0 -1
- package/dist/components/shell/DocInspector.d.ts +0 -18
- package/dist/components/shell/DocInspector.d.ts.map +0 -1
- package/dist/components/shell/DocumentEditorOverlay.d.ts +0 -29
- package/dist/components/shell/DocumentEditorOverlay.d.ts.map +0 -1
- package/dist/components/shell/DocumentPresentOverlay.d.ts +0 -30
- package/dist/components/shell/DocumentPresentOverlay.d.ts.map +0 -1
- package/dist/components/shell/SiteSettingsDecks.d.ts +0 -13
- package/dist/components/shell/SiteSettingsDecks.d.ts.map +0 -1
- package/dist/deck/DeckComponentView.d.ts +0 -13
- package/dist/deck/DeckComponentView.d.ts.map +0 -1
- package/dist/deck/DeckPlayer.d.ts +0 -26
- package/dist/deck/DeckPlayer.d.ts.map +0 -1
- package/dist/deck/DeckSlideView.d.ts +0 -17
- package/dist/deck/DeckSlideView.d.ts.map +0 -1
- package/dist/deck/DeckStage.d.ts +0 -16
- package/dist/deck/DeckStage.d.ts.map +0 -1
- package/dist/deck/components/DeckImage.d.ts +0 -14
- package/dist/deck/components/DeckImage.d.ts.map +0 -1
- package/dist/deck/components/DeckImageGrid.d.ts +0 -27
- package/dist/deck/components/DeckImageGrid.d.ts.map +0 -1
- package/dist/deck/components/DeckRichText.d.ts +0 -22
- package/dist/deck/components/DeckRichText.d.ts.map +0 -1
- package/dist/deck/components/DeckVideo.d.ts +0 -15
- package/dist/deck/components/DeckVideo.d.ts.map +0 -1
- package/dist/deck/editing.d.ts +0 -50
- package/dist/deck/editing.d.ts.map +0 -1
- package/dist/deck/flip.d.ts +0 -11
- package/dist/deck/flip.d.ts.map +0 -1
- package/dist/deck/index.d.ts +0 -24
- package/dist/deck/index.d.ts.map +0 -1
- package/dist/deck/index.js +0 -51
- package/dist/deck/layouts/CenteredTextLayout.d.ts +0 -12
- package/dist/deck/layouts/CenteredTextLayout.d.ts.map +0 -1
- package/dist/deck/layouts/ColumnSplitLayout.d.ts +0 -14
- package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +0 -1
- package/dist/deck/layouts/FullBleedImageLayout.d.ts +0 -14
- package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +0 -1
- package/dist/deck/media.d.ts +0 -33
- package/dist/deck/media.d.ts.map +0 -1
- package/dist/deck/tokens.d.ts +0 -22
- package/dist/deck/tokens.d.ts.map +0 -1
- package/dist/deck/useStageScale.d.ts +0 -10
- package/dist/deck/useStageScale.d.ts.map +0 -1
- package/dist/deck-builder/DeckBuilderApp.d.ts +0 -30
- package/dist/deck-builder/DeckBuilderApp.d.ts.map +0 -1
- package/dist/deck-builder/DeckBuilderTopBar.d.ts +0 -24
- package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +0 -1
- package/dist/deck-builder/DeckChatPanel.d.ts +0 -45
- package/dist/deck-builder/DeckChatPanel.d.ts.map +0 -1
- package/dist/deck-builder/DeckEditor.d.ts +0 -37
- package/dist/deck-builder/DeckEditor.d.ts.map +0 -1
- package/dist/deck-builder/DeckLibrary.d.ts +0 -22
- package/dist/deck-builder/DeckLibrary.d.ts.map +0 -1
- package/dist/deck-builder/DeckSlideSurface.d.ts +0 -31
- package/dist/deck-builder/DeckSlideSurface.d.ts.map +0 -1
- package/dist/deck-builder/deck-api.d.ts +0 -76
- package/dist/deck-builder/deck-api.d.ts.map +0 -1
- package/dist/deck-builder/deck-chat-client.d.ts +0 -44
- package/dist/deck-builder/deck-chat-client.d.ts.map +0 -1
- package/dist/deck-builder/deck-chat-history.d.ts +0 -10
- package/dist/deck-builder/deck-chat-history.d.ts.map +0 -1
- package/dist/deck-builder/index.d.ts +0 -13
- package/dist/deck-builder/index.d.ts.map +0 -1
- package/dist/deck-builder/index.js +0 -1498
- package/dist/deck-builder/starter-deck.d.ts +0 -23
- package/dist/deck-builder/starter-deck.d.ts.map +0 -1
- package/dist/deck-builder/types.d.ts +0 -29
- package/dist/deck-builder/types.d.ts.map +0 -1
- package/dist/deck-builder/useSortableSlide.d.ts +0 -29
- package/dist/deck-builder/useSortableSlide.d.ts.map +0 -1
- package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/dist/document/components/DocCoverPage.d.ts +0 -11
- package/dist/document/components/DocCoverPage.d.ts.map +0 -1
- package/dist/document/components/DocPage.d.ts +0 -10
- package/dist/document/components/DocPage.d.ts.map +0 -1
- package/dist/document/components/DocRail.d.ts +0 -28
- package/dist/document/components/DocRail.d.ts.map +0 -1
- package/dist/document/components/DocSectionPage.d.ts +0 -12
- package/dist/document/components/DocSectionPage.d.ts.map +0 -1
- package/dist/document/components/DocViewer.d.ts +0 -21
- package/dist/document/components/DocViewer.d.ts.map +0 -1
- package/dist/document/components/Wordmark.d.ts +0 -4
- package/dist/document/components/Wordmark.d.ts.map +0 -1
- package/dist/document/components/measure.d.ts +0 -7
- package/dist/document/components/measure.d.ts.map +0 -1
- package/dist/document/components/useDocPages.d.ts +0 -12
- package/dist/document/components/useDocPages.d.ts.map +0 -1
- package/dist/document/engine/blocks.d.ts +0 -2
- package/dist/document/engine/blocks.d.ts.map +0 -1
- package/dist/document/engine/harvest.d.ts +0 -3
- package/dist/document/engine/harvest.d.ts.map +0 -1
- package/dist/document/engine/masonry.d.ts +0 -26
- package/dist/document/engine/masonry.d.ts.map +0 -1
- package/dist/document/engine/paginate.d.ts +0 -43
- package/dist/document/engine/paginate.d.ts.map +0 -1
- package/dist/document/engine/settings.d.ts +0 -91
- package/dist/document/engine/settings.d.ts.map +0 -1
- package/dist/document/engine/types.d.ts +0 -44
- package/dist/document/engine/types.d.ts.map +0 -1
- package/dist/document/index.d.ts +0 -13
- package/dist/document/index.d.ts.map +0 -1
- package/dist/document/index.js +0 -1030
- package/dist/document/styles/document.css +0 -217
- package/dist/lib/deck-access.d.ts +0 -86
- package/dist/lib/deck-access.d.ts.map +0 -1
- package/dist/lib/deck-access.js +0 -58
- package/dist/lib/deck-dexie.d.ts +0 -88
- package/dist/lib/deck-dexie.d.ts.map +0 -1
- package/dist/lib/deck-dexie.js +0 -22
- package/dist/lib/deck-edit.d.ts +0 -67
- package/dist/lib/deck-edit.d.ts.map +0 -1
- package/dist/lib/deck-media-assets.d.ts +0 -27
- package/dist/lib/deck-media-assets.d.ts.map +0 -1
- package/dist/lib/deck-media-assets.js +0 -25
- package/dist/lib/deck-media-refs.d.ts +0 -35
- package/dist/lib/deck-media-refs.d.ts.map +0 -1
- package/dist/lib/deck-media-refs.js +0 -36
- package/dist/lib/deck-tool.d.ts +0 -25
- package/dist/lib/deck-tool.d.ts.map +0 -1
- package/dist/lib/deck-tool.js +0 -58
- package/dist/lib/document-input.d.ts +0 -25
- package/dist/lib/document-input.d.ts.map +0 -1
- package/dist/schemas/deck-meta.d.ts +0 -85
- package/dist/schemas/deck-meta.d.ts.map +0 -1
- package/dist/schemas/deck-meta.js +0 -64
- package/dist/schemas/deck.d.ts +0 -954
- package/dist/schemas/deck.d.ts.map +0 -1
- package/dist/schemas/deck.js +0 -65
- package/src/components/editor/SectionOrderingModal.tsx +0 -80
- package/src/components/shell/DocInspector.tsx +0 -314
- package/src/components/shell/DocumentEditorOverlay.tsx +0 -179
- package/src/components/shell/DocumentPresentOverlay.tsx +0 -87
- package/src/components/shell/SiteSettingsDecks.tsx +0 -41
- package/src/deck/DeckComponentView.tsx +0 -28
- package/src/deck/DeckPlayer.tsx +0 -166
- package/src/deck/DeckSlideView.tsx +0 -38
- package/src/deck/DeckStage.tsx +0 -39
- package/src/deck/components/DeckImage.tsx +0 -38
- package/src/deck/components/DeckImageGrid.tsx +0 -254
- package/src/deck/components/DeckRichText.tsx +0 -111
- package/src/deck/components/DeckVideo.tsx +0 -81
- package/src/deck/editing.tsx +0 -201
- package/src/deck/flip.ts +0 -70
- package/src/deck/index.ts +0 -30
- package/src/deck/layouts/CenteredTextLayout.tsx +0 -29
- package/src/deck/layouts/ColumnSplitLayout.tsx +0 -69
- package/src/deck/layouts/FullBleedImageLayout.tsx +0 -58
- package/src/deck/media.tsx +0 -46
- package/src/deck/tokens.ts +0 -57
- package/src/deck/useStageScale.ts +0 -44
- package/src/deck-builder/DeckBuilderApp.tsx +0 -572
- package/src/deck-builder/DeckBuilderTopBar.tsx +0 -62
- package/src/deck-builder/DeckChatPanel.tsx +0 -334
- package/src/deck-builder/DeckEditor.tsx +0 -258
- package/src/deck-builder/DeckLibrary.tsx +0 -195
- package/src/deck-builder/DeckSlideSurface.tsx +0 -55
- package/src/deck-builder/deck-api.ts +0 -177
- package/src/deck-builder/deck-chat-client.ts +0 -129
- package/src/deck-builder/deck-chat-history.ts +0 -62
- package/src/deck-builder/index.ts +0 -12
- package/src/deck-builder/starter-deck.ts +0 -86
- package/src/deck-builder/types.ts +0 -24
- package/src/deck-builder/useSortableSlide.ts +0 -102
- package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/src/document/components/DocCoverPage.tsx +0 -45
- package/src/document/components/DocPage.tsx +0 -40
- package/src/document/components/DocRail.tsx +0 -189
- package/src/document/components/DocSectionPage.tsx +0 -48
- package/src/document/components/DocViewer.tsx +0 -313
- package/src/document/components/Wordmark.tsx +0 -18
- package/src/document/components/measure.tsx +0 -133
- package/src/document/components/useDocPages.ts +0 -95
- package/src/document/engine/blocks.ts +0 -116
- package/src/document/engine/harvest.ts +0 -95
- package/src/document/engine/masonry.ts +0 -158
- package/src/document/engine/paginate.ts +0 -232
- package/src/document/engine/settings.ts +0 -125
- package/src/document/engine/types.ts +0 -64
- package/src/document/index.ts +0 -18
- package/src/document/styles/document.css +0 -217
- package/src/lib/deck-access.ts +0 -132
- package/src/lib/deck-dexie.ts +0 -190
- package/src/lib/deck-edit.ts +0 -196
- package/src/lib/deck-media-assets.ts +0 -57
- package/src/lib/deck-media-refs.ts +0 -67
- package/src/lib/deck-tool.ts +0 -95
- package/src/lib/document-input.ts +0 -57
- package/src/schemas/deck-meta.ts +0 -88
- package/src/schemas/deck.ts +0 -478
- package/dist/{chunk-DJABADOO.js → chunk-AJ2QOAZI.js} +3 -3
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { cn } from "../../lib/cn";
|
|
3
|
-
import type { DeckImageGrid as DeckImageGridContent, DeckImageGridItem, DeckMediaAsset } from "../../schemas/deck";
|
|
4
|
-
import { captureTileRects, playTileFlip, type TileRects } from "../flip";
|
|
5
|
-
import { DeckMissingMedia, useDeckMediaResolver } from "../media";
|
|
6
|
-
import { DECK_ELEVATION, surfaceInk } from "../tokens";
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
content: DeckImageGridContent;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Two columns for a small set, three once there are enough tiles to fill them.
|
|
14
|
-
* Deliberately not a schema field: it is presentation, and one fewer knob for
|
|
15
|
-
* an author to get wrong.
|
|
16
|
-
*/
|
|
17
|
-
function columnsFor(count: number): number {
|
|
18
|
-
return count <= 4 ? 2 : 3;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A tile's height as a multiple of the column width. Falls back to a square
|
|
23
|
-
* when the resolver reports no dimensions — the packing still has to be
|
|
24
|
-
* deterministic, and a square is the least-wrong guess.
|
|
25
|
-
*/
|
|
26
|
-
function tileRatio(asset: DeckMediaAsset | null): number {
|
|
27
|
-
if (asset?.width === undefined || asset.height === undefined || asset.width <= 0) return 1;
|
|
28
|
-
return asset.height / asset.width;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Greedy shortest-column packing: each tile joins whichever column is currently
|
|
33
|
-
* least tall, ties going left. With uniform aspect ratios that degenerates to
|
|
34
|
-
* round-robin, which is what makes the simple cases look deliberate.
|
|
35
|
-
*/
|
|
36
|
-
export function packMasonry(ratios: number[], columns: number): number[][] {
|
|
37
|
-
const packed: number[][] = Array.from({ length: columns }, () => []);
|
|
38
|
-
const filled = new Array<number>(columns).fill(0);
|
|
39
|
-
ratios.forEach((ratio, index) => {
|
|
40
|
-
let target = 0;
|
|
41
|
-
for (let column = 1; column < columns; column++) {
|
|
42
|
-
if (filled[column] < filled[target]) target = column;
|
|
43
|
-
}
|
|
44
|
-
packed[target].push(index);
|
|
45
|
-
filled[target] += ratio;
|
|
46
|
-
});
|
|
47
|
-
return packed;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const TILE_BASE =
|
|
51
|
-
"group relative block shrink-0 overflow-hidden bg-base-200 will-change-transform transition-[transform,box-shadow,opacity] duration-[380ms] ease-[cubic-bezier(0.2,0.75,0.2,1)] focus-visible:outline-2 focus-visible:outline-offset-[-6px] focus-visible:outline-[var(--color-primary)]";
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Deck Component — image grid, and the runtime's signature interaction.
|
|
55
|
-
*
|
|
56
|
-
* Masonry columns: every image keeps its own aspect ratio, the column width
|
|
57
|
-
* decides its height, and a stack that runs past the bottom of the slot is
|
|
58
|
-
* clipped there. That clip is the point — on a fixed 16:9 stage a collage that
|
|
59
|
-
* runs off the bottom edge reads better than one that crops every frame to a
|
|
60
|
-
* uniform cell, and nothing may spill past the slot onto the rest of the slide.
|
|
61
|
-
*
|
|
62
|
-
* Hover lifts a tile; activating it expands that tile to fill the whole grid
|
|
63
|
-
* region as a lightbox contained inside the slide (never a full-screen overlay
|
|
64
|
-
* — the deck stays the deck). Activating again, the close affordance, or Escape
|
|
65
|
-
* returns it. Tiles are real buttons, so keyboard activation is the browser's.
|
|
66
|
-
*/
|
|
67
|
-
export function DeckImageGrid({ content }: Props) {
|
|
68
|
-
const [active, setActive] = useState<number | null>(null);
|
|
69
|
-
const gridRef = useRef<HTMLDivElement>(null);
|
|
70
|
-
const pendingRects = useRef<TileRects | null>(null);
|
|
71
|
-
|
|
72
|
-
const expanded = active !== null;
|
|
73
|
-
const resolve = useDeckMediaResolver();
|
|
74
|
-
const count = content.items.length;
|
|
75
|
-
|
|
76
|
-
const assets = useMemo(() => content.items.map((item) => resolve(item.media)), [content.items, resolve]);
|
|
77
|
-
const columns = columnsFor(count);
|
|
78
|
-
const packed = useMemo(() => packMasonry(assets.map(tileRatio), columns), [assets, columns]);
|
|
79
|
-
|
|
80
|
-
// Measure BEFORE the state change: React moves the DOM, and FLIP needs the
|
|
81
|
-
// pre-move rects, which no longer exist by the time the effect runs.
|
|
82
|
-
const transitionTo = useCallback((next: number | null) => {
|
|
83
|
-
const grid = gridRef.current;
|
|
84
|
-
if (grid) pendingRects.current = captureTileRects(grid);
|
|
85
|
-
setActive(next);
|
|
86
|
-
}, []);
|
|
87
|
-
|
|
88
|
-
useLayoutEffect(() => {
|
|
89
|
-
const grid = gridRef.current;
|
|
90
|
-
const before = pendingRects.current;
|
|
91
|
-
pendingRects.current = null;
|
|
92
|
-
if (grid && before) playTileFlip(grid, before);
|
|
93
|
-
}, [active]);
|
|
94
|
-
|
|
95
|
-
// The Deck Builder replaces the whole deck every LLM turn, so a grid can lose
|
|
96
|
-
// tiles under an open lightbox. Left alone, the expanded index would point
|
|
97
|
-
// past the end and every tile would render hidden — a blank slot with no way
|
|
98
|
-
// out but Escape.
|
|
99
|
-
useEffect(() => {
|
|
100
|
-
setActive((prev) => (prev === null || prev < count ? prev : null));
|
|
101
|
-
}, [count]);
|
|
102
|
-
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (!expanded) return;
|
|
105
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
106
|
-
if (event.key !== "Escape") return;
|
|
107
|
-
// NOTE: this stops the event travelling to an ancestor, which for a
|
|
108
|
-
// window listener is nothing at all — it does NOT stop other listeners on
|
|
109
|
-
// window from also seeing this Escape. A future shell-level Escape (exit
|
|
110
|
-
// presentation, close the builder) has to coordinate with the grid
|
|
111
|
-
// explicitly; it cannot rely on this call to have swallowed the key.
|
|
112
|
-
event.stopPropagation();
|
|
113
|
-
transitionTo(null);
|
|
114
|
-
};
|
|
115
|
-
window.addEventListener("keydown", onKeyDown);
|
|
116
|
-
return () => window.removeEventListener("keydown", onKeyDown);
|
|
117
|
-
}, [expanded, transitionTo]);
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Close returns focus to the tile that was expanded. Without it the close
|
|
121
|
-
* button unmounts under the caret and focus falls to <body>, which strands a
|
|
122
|
-
* keyboard user outside the grid entirely. Focus first, then re-render, so
|
|
123
|
-
* the unmounting button is no longer the active element when it goes.
|
|
124
|
-
*/
|
|
125
|
-
const close = useCallback(() => {
|
|
126
|
-
gridRef.current?.querySelector<HTMLElement>(`[data-deck-tile="${active}"]`)?.focus({ preventScroll: true });
|
|
127
|
-
transitionTo(null);
|
|
128
|
-
}, [active, transitionTo]);
|
|
129
|
-
|
|
130
|
-
return (
|
|
131
|
-
<div
|
|
132
|
-
ref={gridRef}
|
|
133
|
-
data-deck-component="imageGrid"
|
|
134
|
-
data-deck-grid-expanded={expanded ? "true" : "false"}
|
|
135
|
-
data-deck-grid-columns={columns}
|
|
136
|
-
style={DECK_ELEVATION}
|
|
137
|
-
// Clipped, not scrolled: a deck slide has no scrollbars, and a stack that
|
|
138
|
-
// runs long is meant to run off the bottom of the slot.
|
|
139
|
-
className="relative flex size-full gap-[14px] overflow-hidden"
|
|
140
|
-
>
|
|
141
|
-
{packed.map((indices, column) => (
|
|
142
|
-
// `flex-1` rather than a width fraction: the columns have to share the
|
|
143
|
-
// gap between them, and equal flex bases divide what is left over.
|
|
144
|
-
<div key={column} data-deck-grid-column={column} className="flex min-w-0 flex-1 flex-col gap-[14px]">
|
|
145
|
-
{indices.map((index) => (
|
|
146
|
-
<Tile
|
|
147
|
-
key={index}
|
|
148
|
-
item={content.items[index]}
|
|
149
|
-
asset={assets[index]}
|
|
150
|
-
index={index}
|
|
151
|
-
expanded={expanded}
|
|
152
|
-
active={index === active}
|
|
153
|
-
onActivate={() => transitionTo(index === active ? null : index)}
|
|
154
|
-
/>
|
|
155
|
-
))}
|
|
156
|
-
</div>
|
|
157
|
-
))}
|
|
158
|
-
|
|
159
|
-
{expanded && (
|
|
160
|
-
<button
|
|
161
|
-
type="button"
|
|
162
|
-
data-deck-grid-close=""
|
|
163
|
-
onClick={close}
|
|
164
|
-
data-deck-surface="brand"
|
|
165
|
-
style={surfaceInk("brand")}
|
|
166
|
-
className="absolute top-[14px] right-[14px] z-[4] bg-primary px-[18px] py-[10px] text-[15px] font-semibold tracking-[0.12em] text-[var(--deck-ink)] uppercase"
|
|
167
|
-
>
|
|
168
|
-
Close
|
|
169
|
-
</button>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
interface TileProps {
|
|
176
|
-
item: DeckImageGridItem;
|
|
177
|
-
asset: DeckMediaAsset | null;
|
|
178
|
-
index: number;
|
|
179
|
-
expanded: boolean;
|
|
180
|
-
active: boolean;
|
|
181
|
-
onActivate: () => void;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function Tile({ item, asset, index, expanded, active, onActivate }: TileProps) {
|
|
185
|
-
const label = item.caption ?? asset?.alt ?? `Image ${index + 1}`;
|
|
186
|
-
|
|
187
|
-
return (
|
|
188
|
-
<button
|
|
189
|
-
type="button"
|
|
190
|
-
data-deck-tile={index}
|
|
191
|
-
data-deck-tile-active={active ? "true" : "false"}
|
|
192
|
-
aria-expanded={active}
|
|
193
|
-
aria-label={active ? `Close ${label}` : `Expand ${label}`}
|
|
194
|
-
tabIndex={expanded && !active ? -1 : 0}
|
|
195
|
-
onClick={onActivate}
|
|
196
|
-
className={cn(
|
|
197
|
-
TILE_BASE,
|
|
198
|
-
// Every tile leaves the column flow when one is expanded, not only the
|
|
199
|
-
// active one: leaving the siblings in place would let the columns
|
|
200
|
-
// re-pack around the gap, which both reflows the tiles the lightbox is
|
|
201
|
-
// covering and moves the ones the FLIP is animating.
|
|
202
|
-
expanded ? "absolute inset-0 flex items-center justify-center" : "w-full",
|
|
203
|
-
expanded && !active && "pointer-events-none opacity-0",
|
|
204
|
-
// Transparent, not the tile's loading plate: an expanded frame is
|
|
205
|
-
// letterboxed inside the slot, and the field around it should read as
|
|
206
|
-
// the slide showing through rather than as a grey box drawn on top.
|
|
207
|
-
expanded && active && "z-[3] bg-transparent opacity-100",
|
|
208
|
-
!expanded && "cursor-pointer hover:z-[2] hover:scale-[1.035] hover:shadow-[var(--deck-lift)]",
|
|
209
|
-
)}
|
|
210
|
-
>
|
|
211
|
-
{asset === null ? (
|
|
212
|
-
<DeckMissingMedia className={active ? "size-full" : "aspect-square h-auto w-full"} />
|
|
213
|
-
) : (
|
|
214
|
-
<img
|
|
215
|
-
src={asset.src}
|
|
216
|
-
alt=""
|
|
217
|
-
// The intrinsic size, when the resolver knows it, so the column
|
|
218
|
-
// reserves the right height before a byte arrives — otherwise the
|
|
219
|
-
// masonry re-packs visibly as each image lands.
|
|
220
|
-
width={asset.width}
|
|
221
|
-
height={asset.height}
|
|
222
|
-
// The elevation moves onto the image when expanded: the button box is
|
|
223
|
-
// the whole slot now, and a shadow on THAT would outline the
|
|
224
|
-
// letterbox instead of the frame the viewer is looking at.
|
|
225
|
-
className={
|
|
226
|
-
active ? "max-h-full max-w-full object-contain shadow-[var(--deck-lift-strong)]" : "block h-auto w-full"
|
|
227
|
-
}
|
|
228
|
-
/>
|
|
229
|
-
)}
|
|
230
|
-
|
|
231
|
-
<span
|
|
232
|
-
aria-hidden="true"
|
|
233
|
-
data-deck-surface="brand"
|
|
234
|
-
style={surfaceInk("brand")}
|
|
235
|
-
className="absolute top-0 left-0 bg-primary px-[12px] py-[6px] text-[15px] font-bold tracking-[0.1em] text-[var(--deck-ink)] tabular-nums"
|
|
236
|
-
>
|
|
237
|
-
{String(index + 1).padStart(2, "0")}
|
|
238
|
-
</span>
|
|
239
|
-
|
|
240
|
-
{item.caption !== undefined && (
|
|
241
|
-
<span
|
|
242
|
-
data-deck-surface="brand"
|
|
243
|
-
style={surfaceInk("brand")}
|
|
244
|
-
className={cn(
|
|
245
|
-
"absolute right-0 bottom-0 left-0 bg-primary text-left tracking-[0.04em] text-[var(--deck-ink)] transition-opacity duration-200",
|
|
246
|
-
active ? "px-[30px] py-[22px] text-[24px] opacity-100" : "px-[18px] py-[14px] text-[18px] opacity-0 group-hover:opacity-100",
|
|
247
|
-
)}
|
|
248
|
-
>
|
|
249
|
-
{item.caption}
|
|
250
|
-
</span>
|
|
251
|
-
)}
|
|
252
|
-
</button>
|
|
253
|
-
);
|
|
254
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { Fragment, type ReactNode } from "react";
|
|
2
|
-
import type { DeckTextPath, DeckTextSlot } from "../../lib/deck-edit";
|
|
3
|
-
import type { DeckInlineText, DeckRichText as DeckRichTextContent } from "../../schemas/deck";
|
|
4
|
-
import { EditableText } from "../editing";
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
content: DeckRichTextContent;
|
|
8
|
-
/**
|
|
9
|
-
* Which slot of the enclosing Layout this component fills. Supplied only so a
|
|
10
|
-
* text leaf can address itself for inline editing (#39); absent means "no
|
|
11
|
-
* edit seam here", which is the read-only default every viewer surface gets.
|
|
12
|
-
*/
|
|
13
|
-
slot?: DeckTextSlot;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A body line. A plain string renders as itself; a span run renders its marks
|
|
18
|
-
* as elements. Nothing here reads a string as markup — `bold` and `italic` are
|
|
19
|
-
* booleans on the span, which is the whole point of the representation.
|
|
20
|
-
*
|
|
21
|
-
* The editable unit is the LEAF, never the line: a marked run offers one field
|
|
22
|
-
* per span, so editing the words around a bold phrase cannot flatten it. That
|
|
23
|
-
* is what lets inline editing be plain-text-only without a formatting UI and
|
|
24
|
-
* without silently dropping marks (#39).
|
|
25
|
-
*/
|
|
26
|
-
function InlineText({ value, path }: { value: DeckInlineText; path?: DeckTextPath }) {
|
|
27
|
-
if (typeof value === "string") return <EditableText path={path}>{value}</EditableText>;
|
|
28
|
-
return (
|
|
29
|
-
<>
|
|
30
|
-
{value.map((span, i) => {
|
|
31
|
-
let node: ReactNode = (
|
|
32
|
-
<EditableText path={path && { ...path, span: i }}>{span.text}</EditableText>
|
|
33
|
-
);
|
|
34
|
-
if (span.italic) node = <em className="italic">{node}</em>;
|
|
35
|
-
// Bold pulls the phrase back to full ink: body copy renders muted, so
|
|
36
|
-
// weight alone barely registers at presentation size.
|
|
37
|
-
if (span.bold) node = <strong className="font-semibold text-[var(--deck-ink)]">{node}</strong>;
|
|
38
|
-
return <Fragment key={i}>{node}</Fragment>;
|
|
39
|
-
})}
|
|
40
|
-
</>
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Deck Component — rich text.
|
|
46
|
-
*
|
|
47
|
-
* Every size is px against the 1600x900 stage; every color, family and radius
|
|
48
|
-
* is a token (`--deck-*` are set by the enclosing Layout, see ./tokens.ts).
|
|
49
|
-
* Fields are typographic roles, not markup: the schema carries plain strings
|
|
50
|
-
* and marked spans, so nothing here interpolates HTML.
|
|
51
|
-
*/
|
|
52
|
-
export function DeckRichText({ content, slot }: Props) {
|
|
53
|
-
const { kicker, heading, subheading, paragraphs, bullets } = content;
|
|
54
|
-
const at = (field: DeckTextPath["field"], line?: number): DeckTextPath | undefined =>
|
|
55
|
-
slot === undefined ? undefined : { slot, field, line };
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div data-deck-component="richText" className="flex flex-col gap-[28px]">
|
|
59
|
-
{kicker !== undefined && (
|
|
60
|
-
<div className="flex flex-col gap-[18px]">
|
|
61
|
-
<p className="text-[22px] font-semibold tracking-[0.2em] text-[var(--deck-accent)] [text-transform:var(--nav-text-transform)]">
|
|
62
|
-
<EditableText path={at("kicker")}>{kicker}</EditableText>
|
|
63
|
-
</p>
|
|
64
|
-
<span
|
|
65
|
-
aria-hidden="true"
|
|
66
|
-
// Inline, not `self-[var(…)]`: Tailwind never generated CSS for that
|
|
67
|
-
// arbitrary self- value, so the divider silently ignored --deck-align
|
|
68
|
-
// and hugged the left edge on centered layouts.
|
|
69
|
-
style={{ alignSelf: "var(--deck-align, start)" }}
|
|
70
|
-
className="h-[4px] w-[84px] rounded-[var(--radius-inner)] bg-[var(--deck-accent)]"
|
|
71
|
-
/>
|
|
72
|
-
</div>
|
|
73
|
-
)}
|
|
74
|
-
|
|
75
|
-
{heading !== undefined && (
|
|
76
|
-
<h1 className="font-heading text-[88px] leading-[1.06] font-medium tracking-[-0.015em] text-[var(--deck-ink)] [text-transform:var(--heading-text-transform)]">
|
|
77
|
-
<EditableText path={at("heading")}>{heading}</EditableText>
|
|
78
|
-
</h1>
|
|
79
|
-
)}
|
|
80
|
-
|
|
81
|
-
{subheading !== undefined && (
|
|
82
|
-
<h2 className="font-heading text-[38px] leading-[1.25] font-normal text-[var(--deck-ink-muted)] [text-transform:var(--subheading-text-transform)]">
|
|
83
|
-
<EditableText path={at("subheading")}>{subheading}</EditableText>
|
|
84
|
-
</h2>
|
|
85
|
-
)}
|
|
86
|
-
|
|
87
|
-
{paragraphs !== undefined && paragraphs.length > 0 && (
|
|
88
|
-
<div className="flex max-w-[1000px] flex-col gap-[18px] self-[var(--deck-align,start)]">
|
|
89
|
-
{paragraphs.map((paragraph, i) => (
|
|
90
|
-
<p key={i} className="text-[26px] leading-[1.6] text-[var(--deck-ink-muted)]">
|
|
91
|
-
<InlineText value={paragraph} path={at("paragraphs", i)} />
|
|
92
|
-
</p>
|
|
93
|
-
))}
|
|
94
|
-
</div>
|
|
95
|
-
)}
|
|
96
|
-
|
|
97
|
-
{bullets !== undefined && bullets.length > 0 && (
|
|
98
|
-
<ul className="flex flex-col gap-[16px] self-[var(--deck-align,start)] text-left">
|
|
99
|
-
{bullets.map((bullet, i) => (
|
|
100
|
-
<li key={i} className="flex items-start gap-[18px] text-[26px] leading-[1.45] text-[var(--deck-ink)]">
|
|
101
|
-
<span aria-hidden="true" className="mt-[14px] size-[10px] shrink-0 rounded-full bg-[var(--deck-accent)]" />
|
|
102
|
-
<span>
|
|
103
|
-
<InlineText value={bullet} path={at("bullets", i)} />
|
|
104
|
-
</span>
|
|
105
|
-
</li>
|
|
106
|
-
))}
|
|
107
|
-
</ul>
|
|
108
|
-
)}
|
|
109
|
-
</div>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { Play } from "lucide-react";
|
|
2
|
-
import { useRef, useState } from "react";
|
|
3
|
-
import type { DeckVideo as DeckVideoContent } from "../../schemas/deck";
|
|
4
|
-
import { DeckMissingMedia, useDeckMedia } from "../media";
|
|
5
|
-
import { DECK_ELEVATION, surfaceInk } from "../tokens";
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
content: DeckVideoContent;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Deck Component — video.
|
|
13
|
-
*
|
|
14
|
-
* Two states: a poster with a play affordance, and playback with the browser's
|
|
15
|
-
* own controls. Nothing custom drives the media element once it is playing —
|
|
16
|
-
* a presenter needs scrubbing and volume, and native controls are the ones
|
|
17
|
-
* their keyboard already knows.
|
|
18
|
-
*/
|
|
19
|
-
export function DeckVideo({ content }: Props) {
|
|
20
|
-
const asset = useDeckMedia(content.media);
|
|
21
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
22
|
-
const [playing, setPlaying] = useState(false);
|
|
23
|
-
|
|
24
|
-
const start = () => {
|
|
25
|
-
setPlaying(true);
|
|
26
|
-
// Rejects when the source is unplayable (wrong codec, a media id whose
|
|
27
|
-
// bytes are gone) — the poster is already dismissed, so just don't leave
|
|
28
|
-
// an unhandled rejection behind.
|
|
29
|
-
videoRef.current?.play()?.catch(() => undefined);
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<div
|
|
34
|
-
data-deck-component="video"
|
|
35
|
-
data-deck-video-state={playing ? "playing" : "idle"}
|
|
36
|
-
style={DECK_ELEVATION}
|
|
37
|
-
className="relative size-full overflow-hidden bg-base-200"
|
|
38
|
-
>
|
|
39
|
-
{asset === null ? (
|
|
40
|
-
<DeckMissingMedia />
|
|
41
|
-
) : (
|
|
42
|
-
<video
|
|
43
|
-
ref={videoRef}
|
|
44
|
-
src={asset.src}
|
|
45
|
-
poster={asset.poster}
|
|
46
|
-
// Nothing is fetched until the poster is dismissed: a deck is mostly
|
|
47
|
-
// slides you never reach, and the poster is an explicit URL anyway.
|
|
48
|
-
preload="none"
|
|
49
|
-
playsInline
|
|
50
|
-
controls={playing}
|
|
51
|
-
onEnded={() => setPlaying(false)}
|
|
52
|
-
className="size-full object-cover"
|
|
53
|
-
/>
|
|
54
|
-
)}
|
|
55
|
-
|
|
56
|
-
{!playing && asset !== null && (
|
|
57
|
-
<button
|
|
58
|
-
type="button"
|
|
59
|
-
data-deck-video-play=""
|
|
60
|
-
onClick={start}
|
|
61
|
-
aria-label={content.title === undefined ? "Play video" : `Play ${content.title}`}
|
|
62
|
-
className="group absolute inset-0 grid cursor-pointer place-items-center"
|
|
63
|
-
>
|
|
64
|
-
<span className="grid size-[108px] place-items-center rounded-full bg-primary text-[var(--color-on-primary)] shadow-[var(--deck-lift)] transition-transform duration-200 group-hover:scale-105">
|
|
65
|
-
<Play aria-hidden="true" className="ml-[6px] size-[44px] fill-current" />
|
|
66
|
-
</span>
|
|
67
|
-
</button>
|
|
68
|
-
)}
|
|
69
|
-
|
|
70
|
-
{!playing && content.title !== undefined && (
|
|
71
|
-
<span
|
|
72
|
-
data-deck-surface="brand"
|
|
73
|
-
style={surfaceInk("brand")}
|
|
74
|
-
className="pointer-events-none absolute right-0 bottom-0 left-0 bg-primary px-[28px] py-[18px] text-[18px] tracking-[0.06em] text-[var(--deck-ink)] [text-transform:var(--nav-text-transform)]"
|
|
75
|
-
>
|
|
76
|
-
{content.title}
|
|
77
|
-
</span>
|
|
78
|
-
)}
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
}
|
package/src/deck/editing.tsx
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import type { DeckTextPath } from "../lib/deck-edit";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The runtime's inline-editing seam (#39).
|
|
6
|
-
*
|
|
7
|
-
* Editing happens INSIDE the real renderer rather than in an overlay that
|
|
8
|
-
* imitates it, which is the only way "renders identically on blur" is true
|
|
9
|
-
* rather than approximately true — the element the author types into is the
|
|
10
|
-
* element the deck ships with.
|
|
11
|
-
*
|
|
12
|
-
* The seam is a context that defaults to null, so every read-only surface — a
|
|
13
|
-
* filmstrip thumbnail, an in-portal deck view, the Public Link route — renders
|
|
14
|
-
* exactly the markup it rendered before this file existed, with no affordance
|
|
15
|
-
* and no listeners. Providing the context is an explicit act, performed in one
|
|
16
|
-
* place (`DeckSlideSurface`).
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
export interface DeckTextEditor {
|
|
20
|
-
/** Commit a leaf edit. The provider owns validation and staging. */
|
|
21
|
-
onEditText: (path: DeckTextPath, text: string) => void;
|
|
22
|
-
/**
|
|
23
|
-
* Locked while a chat turn is in flight: a turn rewrites the WHOLE deck
|
|
24
|
-
* (ADR 0001), so an edit committed mid-stream would be written over by the
|
|
25
|
-
* turn's own `putDeckDraft` with nothing anywhere saying so.
|
|
26
|
-
*/
|
|
27
|
-
disabled?: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const DeckTextEditContext = createContext<DeckTextEditor | null>(null);
|
|
31
|
-
|
|
32
|
-
export function DeckTextEditProvider({ editor, children }: { editor: DeckTextEditor | null; children: ReactNode }) {
|
|
33
|
-
return <DeckTextEditContext.Provider value={editor}>{children}</DeckTextEditContext.Provider>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function useDeckTextEditor(): DeckTextEditor | null {
|
|
37
|
-
return useContext(DeckTextEditContext);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface EditableTextProps {
|
|
41
|
-
/** Absent when the enclosing component was rendered without a slot — i.e. by
|
|
42
|
-
* a surface that never carries an edit seam. Renders inert. */
|
|
43
|
-
path?: DeckTextPath;
|
|
44
|
-
/** The leaf's current text. Always a plain string; that is the schema's shape. */
|
|
45
|
-
children: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* One editable text leaf, or plain text when there is no editor.
|
|
50
|
-
*
|
|
51
|
-
* The two states are DIFFERENT ELEMENTS on purpose. React does not manage the
|
|
52
|
-
* text of a `contentEditable` node it has no children for, so the field can be
|
|
53
|
-
* written to freely while it is open, and swapping back to a React-rendered
|
|
54
|
-
* span on commit is what makes the committed value the one that renders — no
|
|
55
|
-
* reconciliation against a DOM the author mutated behind React's back.
|
|
56
|
-
*/
|
|
57
|
-
export function EditableText({ path, children }: EditableTextProps) {
|
|
58
|
-
const editor = useDeckTextEditor();
|
|
59
|
-
const [editing, setEditing] = useState(false);
|
|
60
|
-
|
|
61
|
-
const enabled = editor !== null && path !== undefined && editor.disabled !== true;
|
|
62
|
-
|
|
63
|
-
// Losing the seam mid-edit (a chat turn starting, the surface going
|
|
64
|
-
// read-only) closes the field rather than leaving an orphaned editable node.
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (!enabled && editing) setEditing(false);
|
|
67
|
-
}, [enabled, editing]);
|
|
68
|
-
|
|
69
|
-
if (editor === null || path === undefined) return <>{children}</>;
|
|
70
|
-
|
|
71
|
-
if (editing && enabled) {
|
|
72
|
-
return (
|
|
73
|
-
<EditableField
|
|
74
|
-
initial={children}
|
|
75
|
-
onCommit={(text) => {
|
|
76
|
-
setEditing(false);
|
|
77
|
-
// An emptied field is a cancel, not an edit: every one of these leaves
|
|
78
|
-
// is `min(1)` in the schema, so committing "" could only ever be
|
|
79
|
-
// refused — and refusing it with an error message would read as a
|
|
80
|
-
// failure rather than as "nothing happened".
|
|
81
|
-
const trimmed = text.trim();
|
|
82
|
-
if (trimmed !== "" && trimmed !== children) editor.onEditText(path, trimmed);
|
|
83
|
-
}}
|
|
84
|
-
onCancel={() => setEditing(false)}
|
|
85
|
-
/>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return (
|
|
90
|
-
<span
|
|
91
|
-
role="button"
|
|
92
|
-
tabIndex={enabled ? 0 : -1}
|
|
93
|
-
aria-disabled={enabled ? undefined : true}
|
|
94
|
-
data-deck-editable=""
|
|
95
|
-
onClick={enabled ? () => setEditing(true) : undefined}
|
|
96
|
-
onKeyDown={
|
|
97
|
-
enabled
|
|
98
|
-
? (event) => {
|
|
99
|
-
if (event.key !== "Enter" && event.key !== " ") return;
|
|
100
|
-
event.preventDefault();
|
|
101
|
-
setEditing(true);
|
|
102
|
-
}
|
|
103
|
-
: undefined
|
|
104
|
-
}
|
|
105
|
-
className={
|
|
106
|
-
enabled
|
|
107
|
-
? "cursor-text rounded-[var(--radius-inner)] decoration-dotted underline-offset-[6px] outline-none hover:underline focus-visible:underline"
|
|
108
|
-
: "opacity-70"
|
|
109
|
-
}
|
|
110
|
-
>
|
|
111
|
-
{children}
|
|
112
|
-
</span>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The open field. Rendered with no React children so React never fights the
|
|
118
|
-
* author for the node's text; the initial value is written once on mount.
|
|
119
|
-
*/
|
|
120
|
-
function EditableField({
|
|
121
|
-
initial,
|
|
122
|
-
onCommit,
|
|
123
|
-
onCancel,
|
|
124
|
-
}: {
|
|
125
|
-
initial: string;
|
|
126
|
-
onCommit: (text: string) => void;
|
|
127
|
-
onCancel: () => void;
|
|
128
|
-
}) {
|
|
129
|
-
const ref = useRef<HTMLSpanElement>(null);
|
|
130
|
-
// Escape has to beat the blur that follows it, and blur reads this ref rather
|
|
131
|
-
// than state because the two happen in the same tick.
|
|
132
|
-
const cancelled = useRef(false);
|
|
133
|
-
|
|
134
|
-
useEffect(() => {
|
|
135
|
-
const element = ref.current;
|
|
136
|
-
if (!element) return;
|
|
137
|
-
element.textContent = initial;
|
|
138
|
-
element.focus();
|
|
139
|
-
const selection = window.getSelection?.();
|
|
140
|
-
if (selection) {
|
|
141
|
-
const range = document.createRange();
|
|
142
|
-
range.selectNodeContents(element);
|
|
143
|
-
selection.removeAllRanges();
|
|
144
|
-
selection.addRange(range);
|
|
145
|
-
}
|
|
146
|
-
}, [initial]);
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<span
|
|
150
|
-
ref={ref}
|
|
151
|
-
contentEditable
|
|
152
|
-
suppressContentEditableWarning
|
|
153
|
-
role="textbox"
|
|
154
|
-
aria-label="Edit text"
|
|
155
|
-
spellCheck
|
|
156
|
-
data-deck-editing=""
|
|
157
|
-
onKeyDown={(event) => {
|
|
158
|
-
// Mid-composition, Enter and Escape belong to the IME: Enter accepts a
|
|
159
|
-
// conversion candidate and Escape abandons it. Committing or cancelling
|
|
160
|
-
// the field there cuts a CJK author off mid-word.
|
|
161
|
-
if (event.nativeEvent.isComposing) return;
|
|
162
|
-
if (event.key === "Enter") {
|
|
163
|
-
// A deck text leaf is one line in the schema, so Enter means "done",
|
|
164
|
-
// not "newline" — the layouts own line breaking.
|
|
165
|
-
event.preventDefault();
|
|
166
|
-
event.currentTarget.blur();
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (event.key === "Escape") {
|
|
170
|
-
event.preventDefault();
|
|
171
|
-
cancelled.current = true;
|
|
172
|
-
event.currentTarget.blur();
|
|
173
|
-
}
|
|
174
|
-
}}
|
|
175
|
-
onPaste={(event) => {
|
|
176
|
-
// A deck text leaf is a plain string, and the field yields `textContent`
|
|
177
|
-
// — so pasted markup would show live formatting that vanishes on blur,
|
|
178
|
-
// and pasted block elements would join with no separator at all
|
|
179
|
-
// (`<div>a</div><div>b</div>` reads back as "ab"). Take the plain text
|
|
180
|
-
// and flatten its line breaks to spaces, which is what a one-line field
|
|
181
|
-
// can honestly hold.
|
|
182
|
-
event.preventDefault();
|
|
183
|
-
const text = event.clipboardData.getData("text/plain").replace(/\s+/g, " ").trim();
|
|
184
|
-
const doc = event.currentTarget.ownerDocument;
|
|
185
|
-
// Guarded because jsdom does not implement it; `insertText` is used
|
|
186
|
-
// rather than a Selection-API splice because it is what keeps the
|
|
187
|
-
// field's native undo stack intact.
|
|
188
|
-
if (text !== "" && typeof doc.execCommand === "function") doc.execCommand("insertText", false, text);
|
|
189
|
-
}}
|
|
190
|
-
onBlur={(event) => {
|
|
191
|
-
if (cancelled.current) {
|
|
192
|
-
cancelled.current = false;
|
|
193
|
-
onCancel();
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
onCommit(event.currentTarget.textContent ?? "");
|
|
197
|
-
}}
|
|
198
|
-
className="rounded-[var(--radius-inner)] outline-2 outline-offset-2 outline-[var(--deck-accent)]"
|
|
199
|
-
/>
|
|
200
|
-
);
|
|
201
|
-
}
|