@elytracms/studio 0.0.19 → 0.0.21
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/components/assets/asset-metadata.d.ts +9 -0
- package/dist/components/assets/asset-metadata.js +3 -0
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-thumb.d.ts +28 -0
- package/dist/components/assets/asset-thumb.js +55 -0
- package/dist/components/assets/asset-thumb.js.map +1 -0
- package/dist/components/assets/focal-point.d.ts +38 -0
- package/dist/components/assets/focal-point.js +47 -0
- package/dist/components/assets/focal-point.js.map +1 -0
- package/dist/components/builder/builder-store.d.ts +12 -0
- package/dist/components/builder/builder-store.js +15 -1
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas.d.ts +7 -1
- package/dist/components/builder/canvas.js +63 -6
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +3 -2
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/page-tree.js +8 -2
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +28 -0
- package/dist/components/builder/reuse-context.js +128 -0
- package/dist/components/builder/reuse-context.js.map +1 -0
- package/dist/components/builder/reuse.d.ts +59 -0
- package/dist/components/builder/reuse.js +117 -0
- package/dist/components/builder/reuse.js.map +1 -0
- package/dist/components/cms/cms-store.js +29 -6
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/detail-editor.js +78 -7
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +32 -1
- package/dist/components/cms/field-control.js +216 -17
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/section-nav.js +37 -7
- package/dist/components/section-nav.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +12 -3
- package/dist/lib/data/collection-content.js +20 -17
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/rail.d.ts +10 -0
- package/dist/lib/rail.js +25 -0
- package/dist/lib/rail.js.map +1 -1
- package/package.json +2 -2
|
@@ -14,6 +14,15 @@ export interface AssetMetadataPatch {
|
|
|
14
14
|
alt?: string;
|
|
15
15
|
/** New display title; same present/empty/absent semantics as `alt`. */
|
|
16
16
|
title?: string;
|
|
17
|
+
/**
|
|
18
|
+
* New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's
|
|
19
|
+
* `object-position` (EC-230). A present `{x,y}` sets it, `null` clears it, an
|
|
20
|
+
* absent key leaves the current value untouched.
|
|
21
|
+
*/
|
|
22
|
+
focalPoint?: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
} | null;
|
|
17
26
|
}
|
|
18
27
|
/** The full upsert input that re-states `current` with the patch applied. */
|
|
19
28
|
export declare function assetMetadataUpsertInput(current: AssetRecord, patch: AssetMetadataPatch): NewAssetInput;
|
|
@@ -9,6 +9,8 @@ function mergeField(patchValue, patchHasKey, currentValue) {
|
|
|
9
9
|
export function assetMetadataUpsertInput(current, patch) {
|
|
10
10
|
const alt = mergeField(patch.alt, 'alt' in patch, current.alt);
|
|
11
11
|
const title = mergeField(patch.title, 'title' in patch, current.title);
|
|
12
|
+
// present `{x,y}` sets, present `null` clears, absent keeps the current point.
|
|
13
|
+
const focalPoint = 'focalPoint' in patch ? (patch.focalPoint ?? undefined) : current.focalPoint;
|
|
12
14
|
return {
|
|
13
15
|
id: current.id,
|
|
14
16
|
filename: current.filename,
|
|
@@ -20,6 +22,7 @@ export function assetMetadataUpsertInput(current, patch) {
|
|
|
20
22
|
...(current.url !== undefined ? { url: current.url } : {}),
|
|
21
23
|
...(alt !== undefined ? { alt } : {}),
|
|
22
24
|
...(title !== undefined ? { title } : {}),
|
|
25
|
+
...(focalPoint !== undefined ? { focalPoint } : {}),
|
|
23
26
|
};
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"AA0BA,qFAAqF;AACrF,SAAS,UAAU,CACjB,UAA8B,EAC9B,WAAoB,EACpB,YAAgC;IAEhC,IAAI,CAAC,WAAW;QAAE,OAAO,YAAY,CAAA;IACrC,MAAM,OAAO,GAAG,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;IACxC,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AACjD,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,wBAAwB,CACtC,OAAoB,EACpB,KAAyB;IAEzB,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,IAAI,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,CAAA;IAC9D,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;IACtE,+EAA+E;IAC/E,MAAM,UAAU,GAAG,YAAY,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAA;IAC/F,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,EAAE;QACd,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,WAAW,EAAE,OAAO,CAAC,WAAW;QAChC,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,GAAG,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAChE,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACnE,GAAG,CAAC,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1D,GAAG,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACrC,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzC,GAAG,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACpD,CAAA;AACH,CAAC;AAMD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,IAAiC,EACjC,SAAiB,EACjB,OAAe,EACf,KAAyB;IAEzB,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;QAC9D,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,SAAS,EAAE,wBAAwB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;QAChG,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAA;IAC5B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IACvF,CAAC;AACH,CAAC","sourcesContent":["import type { AssetRecord, AssetRepository, NewAssetInput } from '@elytracms/core/persistence'\n\n/**\n * Asset metadata editing (EC-152). A patch is applied through the\n * {@link AssetRepository} interface — in the studio that is the EC-141\n * operations facade, so every edit runs the `assets.upsert` command\n * (validate → authorize → mutate → event). Pure orchestration; injectable\n * for tests.\n */\n\nexport interface AssetMetadataPatch {\n /**\n * New alt text. A present key overwrites; a trimmed-empty string clears\n * the field. An absent key leaves the current value untouched.\n */\n alt?: string\n /** New display title; same present/empty/absent semantics as `alt`. */\n title?: string\n /**\n * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's\n * `object-position` (EC-230). A present `{x,y}` sets it, `null` clears it, an\n * absent key leaves the current value untouched.\n */\n focalPoint?: { x: number; y: number } | null\n}\n\n/** Merge one patch field: present+empty clears, present+value sets, absent keeps. */\nfunction mergeField(\n patchValue: string | undefined,\n patchHasKey: boolean,\n currentValue: string | undefined,\n): string | undefined {\n if (!patchHasKey) return currentValue\n const trimmed = patchValue?.trim() ?? ''\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/** The full upsert input that re-states `current` with the patch applied. */\nexport function assetMetadataUpsertInput(\n current: AssetRecord,\n patch: AssetMetadataPatch,\n): NewAssetInput {\n const alt = mergeField(patch.alt, 'alt' in patch, current.alt)\n const title = mergeField(patch.title, 'title' in patch, current.title)\n // present `{x,y}` sets, present `null` clears, absent keeps the current point.\n const focalPoint = 'focalPoint' in patch ? (patch.focalPoint ?? undefined) : current.focalPoint\n return {\n id: current.id,\n filename: current.filename,\n contentType: current.contentType,\n byteSize: current.byteSize,\n storageKey: current.storageKey,\n ...(current.width !== undefined ? { width: current.width } : {}),\n ...(current.height !== undefined ? { height: current.height } : {}),\n ...(current.url !== undefined ? { url: current.url } : {}),\n ...(alt !== undefined ? { alt } : {}),\n ...(title !== undefined ? { title } : {}),\n ...(focalPoint !== undefined ? { focalPoint } : {}),\n }\n}\n\nexport type UpdateAssetMetadataResult =\n | { ok: true; asset: AssetRecord }\n | { ok: false; message: string }\n\n/**\n * Load the current record, apply the patch, and persist it via the\n * repository (the operations facade in the studio). Failures come back as a\n * structured result — never a throw the UI has to guess about.\n */\nexport async function updateAssetMetadata(\n deps: { assets: AssetRepository },\n projectId: string,\n assetId: string,\n patch: AssetMetadataPatch,\n): Promise<UpdateAssetMetadataResult> {\n try {\n const current = await deps.assets.getAsset(projectId, assetId)\n const asset = await deps.assets.upsertAsset(projectId, assetMetadataUpsertInput(current, patch))\n return { ok: true, asset }\n } catch (cause) {\n return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }\n }\n}\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AssetRecord } from '@elytracms/core/persistence';
|
|
2
|
+
import type { FocalPoint } from './focal-point';
|
|
3
|
+
/** Resolve an asset record by id from the ambient library, if one is mounted. */
|
|
4
|
+
export declare function useAssetRecord(assetId: string | null | undefined): AssetRecord | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* A read-only image thumbnail honouring the asset's focal point (EC-248): the
|
|
7
|
+
* preview crops with the same `object-position` delivery uses (EC-230), so the
|
|
8
|
+
* editor sees exactly what a focal-point change does. Non-image / unresolvable
|
|
9
|
+
* assets fall back to the labelled icon, matching the table cell's preview.
|
|
10
|
+
*/
|
|
11
|
+
export declare function AssetThumb({ record, className, alt, }: {
|
|
12
|
+
record: AssetRecord | undefined;
|
|
13
|
+
className?: string;
|
|
14
|
+
alt?: string;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
16
|
+
/**
|
|
17
|
+
* The focal-point picker (EC-248): click (or keyboard-nudge) anywhere on the
|
|
18
|
+
* image to place the normalized 0–1 hotspot delivery threads to `object-position`
|
|
19
|
+
* (EC-230). The marker shows the current point; clicking commits a new one,
|
|
20
|
+
* `onChange(null)` resets to centre. Renders a muted note when the asset has no
|
|
21
|
+
* resolvable image to place a point on.
|
|
22
|
+
*/
|
|
23
|
+
export declare function FocalPointPicker({ record, point, onChange, disabled, }: {
|
|
24
|
+
record: AssetRecord | undefined;
|
|
25
|
+
point: FocalPoint | undefined;
|
|
26
|
+
onChange: (point: FocalPoint | null) => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { Image } from 'lucide-react';
|
|
4
|
+
import { Text } from '@elytracms/studio/ui';
|
|
5
|
+
import { cn } from '@elytracms/studio/lib/utils';
|
|
6
|
+
import { useOptionalAssets } from './assets-store';
|
|
7
|
+
import { CENTER_FOCAL_POINT, focalPointFromPoint, focalPointObjectPosition, } from './focal-point';
|
|
8
|
+
/** Resolve an asset record by id from the ambient library, if one is mounted. */
|
|
9
|
+
export function useAssetRecord(assetId) {
|
|
10
|
+
const store = useOptionalAssets();
|
|
11
|
+
return useMemo(() => {
|
|
12
|
+
if (!assetId || !store || store.status !== 'ready')
|
|
13
|
+
return undefined;
|
|
14
|
+
return store.assets.find((a) => a.id === assetId);
|
|
15
|
+
}, [assetId, store]);
|
|
16
|
+
}
|
|
17
|
+
/** True when the record is an image with a browser-resolvable URL (EC-212). */
|
|
18
|
+
function isResolvableImage(record) {
|
|
19
|
+
return (record !== undefined &&
|
|
20
|
+
record.contentType.startsWith('image/') &&
|
|
21
|
+
typeof record.url === 'string' &&
|
|
22
|
+
record.url.startsWith('http'));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A read-only image thumbnail honouring the asset's focal point (EC-248): the
|
|
26
|
+
* preview crops with the same `object-position` delivery uses (EC-230), so the
|
|
27
|
+
* editor sees exactly what a focal-point change does. Non-image / unresolvable
|
|
28
|
+
* assets fall back to the labelled icon, matching the table cell's preview.
|
|
29
|
+
*/
|
|
30
|
+
export function AssetThumb({ record, className, alt, }) {
|
|
31
|
+
if (isResolvableImage(record)) {
|
|
32
|
+
return (_jsx("img", { src: record.url, alt: alt ?? record.alt ?? record.title ?? record.filename, loading: "lazy", style: { objectPosition: focalPointObjectPosition(record.focalPoint) }, className: cn('rounded border border-border bg-muted object-cover', className) }));
|
|
33
|
+
}
|
|
34
|
+
return (_jsx("div", { className: cn('flex items-center justify-center rounded border border-border bg-muted text-muted-foreground', className), children: _jsx(Image, { className: "size-4", "aria-hidden": true }) }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The focal-point picker (EC-248): click (or keyboard-nudge) anywhere on the
|
|
38
|
+
* image to place the normalized 0–1 hotspot delivery threads to `object-position`
|
|
39
|
+
* (EC-230). The marker shows the current point; clicking commits a new one,
|
|
40
|
+
* `onChange(null)` resets to centre. Renders a muted note when the asset has no
|
|
41
|
+
* resolvable image to place a point on.
|
|
42
|
+
*/
|
|
43
|
+
export function FocalPointPicker({ record, point, onChange, disabled, }) {
|
|
44
|
+
const marker = point ?? CENTER_FOCAL_POINT;
|
|
45
|
+
if (!isResolvableImage(record)) {
|
|
46
|
+
return (_jsx(Text, { size: "xs", tone: "subtle", children: "No image preview \u2014 upload or pick an image asset to set a focal point." }));
|
|
47
|
+
}
|
|
48
|
+
function place(clientX, clientY, target) {
|
|
49
|
+
if (disabled)
|
|
50
|
+
return;
|
|
51
|
+
onChange(focalPointFromPoint(target.getBoundingClientRect(), clientX, clientY));
|
|
52
|
+
}
|
|
53
|
+
return (_jsxs("button", { type: "button", "aria-label": "Set focal point", disabled: disabled, onClick: (e) => place(e.clientX, e.clientY, e.currentTarget), className: cn('relative block w-full overflow-hidden rounded border border-border bg-muted', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40', disabled ? 'cursor-default opacity-60' : 'cursor-crosshair'), children: [_jsx("img", { src: record.url, alt: record.alt ?? record.filename, loading: "lazy", draggable: false, className: "block h-auto w-full select-none" }), _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40", style: { left: `${marker.x * 100}%`, top: `${marker.y * 100}%` } })] }));
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=asset-thumb.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"asset-thumb.js","sourceRoot":"","sources":["../../../src/components/assets/asset-thumb.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo } from 'react'\nimport { Image } from 'lucide-react'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport { Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { useOptionalAssets } from './assets-store'\nimport {\n CENTER_FOCAL_POINT,\n focalPointFromPoint,\n focalPointObjectPosition,\n} from './focal-point'\nimport type { FocalPoint } from './focal-point'\n\n/** Resolve an asset record by id from the ambient library, if one is mounted. */\nexport function useAssetRecord(assetId: string | null | undefined): AssetRecord | undefined {\n const store = useOptionalAssets()\n return useMemo(() => {\n if (!assetId || !store || store.status !== 'ready') return undefined\n return store.assets.find((a) => a.id === assetId)\n }, [assetId, store])\n}\n\n/** True when the record is an image with a browser-resolvable URL (EC-212). */\nfunction isResolvableImage(record: AssetRecord | undefined): record is AssetRecord & { url: string } {\n return (\n record !== undefined &&\n record.contentType.startsWith('image/') &&\n typeof record.url === 'string' &&\n record.url.startsWith('http')\n )\n}\n\n/**\n * A read-only image thumbnail honouring the asset's focal point (EC-248): the\n * preview crops with the same `object-position` delivery uses (EC-230), so the\n * editor sees exactly what a focal-point change does. Non-image / unresolvable\n * assets fall back to the labelled icon, matching the table cell's preview.\n */\nexport function AssetThumb({\n record,\n className,\n alt,\n}: {\n record: AssetRecord | undefined\n className?: string\n alt?: string\n}) {\n if (isResolvableImage(record)) {\n return (\n <img\n src={record.url}\n alt={alt ?? record.alt ?? record.title ?? record.filename}\n loading=\"lazy\"\n style={{ objectPosition: focalPointObjectPosition(record.focalPoint) }}\n className={cn('rounded border border-border bg-muted object-cover', className)}\n />\n )\n }\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded border border-border bg-muted text-muted-foreground',\n className,\n )}\n >\n <Image className=\"size-4\" aria-hidden />\n </div>\n )\n}\n\n/**\n * The focal-point picker (EC-248): click (or keyboard-nudge) anywhere on the\n * image to place the normalized 0–1 hotspot delivery threads to `object-position`\n * (EC-230). The marker shows the current point; clicking commits a new one,\n * `onChange(null)` resets to centre. Renders a muted note when the asset has no\n * resolvable image to place a point on.\n */\nexport function FocalPointPicker({\n record,\n point,\n onChange,\n disabled,\n}: {\n record: AssetRecord | undefined\n point: FocalPoint | undefined\n onChange: (point: FocalPoint | null) => void\n disabled?: boolean\n}) {\n const marker = point ?? CENTER_FOCAL_POINT\n\n if (!isResolvableImage(record)) {\n return (\n <Text size=\"xs\" tone=\"subtle\">\n No image preview — upload or pick an image asset to set a focal point.\n </Text>\n )\n }\n\n function place(clientX: number, clientY: number, target: HTMLElement) {\n if (disabled) return\n onChange(focalPointFromPoint(target.getBoundingClientRect(), clientX, clientY))\n }\n\n return (\n <button\n type=\"button\"\n aria-label=\"Set focal point\"\n disabled={disabled}\n onClick={(e) => place(e.clientX, e.clientY, e.currentTarget)}\n className={cn(\n 'relative block w-full overflow-hidden rounded border border-border bg-muted',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',\n disabled ? 'cursor-default opacity-60' : 'cursor-crosshair',\n )}\n >\n {/* Width-constrained, natural aspect (no object-fit), so the button wraps\n the image tightly and the bounding rect equals the image box — the\n normalized click coordinates are then exact, with no letterbox offset. */}\n <img\n src={record.url}\n alt={record.alt ?? record.filename}\n loading=\"lazy\"\n draggable={false}\n className=\"block h-auto w-full select-none\"\n />\n <span\n aria-hidden\n className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40\"\n style={{ left: `${marker.x * 100}%`, top: `${marker.y * 100}%` }}\n />\n </button>\n )\n}\n"]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure focal-point + upload-selection helpers for the image-field authoring UI
|
|
3
|
+
* (EC-248). Kept side-effect-free and DOM-free so the coordinate math and the
|
|
4
|
+
* "which upload just landed" reconciliation are unit-testable without a browser.
|
|
5
|
+
*
|
|
6
|
+
* A focal point is the EC-230 normalized 0–1 hotspot stored on the asset record
|
|
7
|
+
* and threaded to delivery's `object-position`. The picker lets an editor place
|
|
8
|
+
* it directly on the image preview.
|
|
9
|
+
*/
|
|
10
|
+
/** A normalized focal point — both axes in `[0, 1]`. */
|
|
11
|
+
export interface FocalPoint {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
/** Clamp a number into the unit interval `[0, 1]`. */
|
|
16
|
+
export declare function clampUnit(n: number): number;
|
|
17
|
+
/** The neutral default focal point (image centre) — what an unset hotspot renders as. */
|
|
18
|
+
export declare const CENTER_FOCAL_POINT: FocalPoint;
|
|
19
|
+
/**
|
|
20
|
+
* Convert a pointer position over the preview's bounding box into a normalized
|
|
21
|
+
* focal point. A degenerate (zero-area) box falls back to centre rather than
|
|
22
|
+
* dividing by zero, so a not-yet-laid-out element never yields `NaN`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function focalPointFromPoint(rect: {
|
|
25
|
+
left: number;
|
|
26
|
+
top: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}, clientX: number, clientY: number): FocalPoint;
|
|
30
|
+
/** CSS `object-position` for a focal point (percent), matching delivery (EC-230). */
|
|
31
|
+
export declare function focalPointObjectPosition(point: FocalPoint | undefined): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The asset ids present in `after` but not in `before` — the records that an
|
|
34
|
+
* upload added to the library. The field uses this to auto-select an asset it
|
|
35
|
+
* just uploaded (the `upload()` API is fire-and-forget, so the new id is learned
|
|
36
|
+
* by diffing the library, not returned). Order follows `after`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function newlyAddedAssetIds(before: Iterable<string>, after: readonly string[]): string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure focal-point + upload-selection helpers for the image-field authoring UI
|
|
3
|
+
* (EC-248). Kept side-effect-free and DOM-free so the coordinate math and the
|
|
4
|
+
* "which upload just landed" reconciliation are unit-testable without a browser.
|
|
5
|
+
*
|
|
6
|
+
* A focal point is the EC-230 normalized 0–1 hotspot stored on the asset record
|
|
7
|
+
* and threaded to delivery's `object-position`. The picker lets an editor place
|
|
8
|
+
* it directly on the image preview.
|
|
9
|
+
*/
|
|
10
|
+
/** Clamp a number into the unit interval `[0, 1]`. */
|
|
11
|
+
export function clampUnit(n) {
|
|
12
|
+
if (Number.isNaN(n))
|
|
13
|
+
return 0.5;
|
|
14
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
15
|
+
}
|
|
16
|
+
/** The neutral default focal point (image centre) — what an unset hotspot renders as. */
|
|
17
|
+
export const CENTER_FOCAL_POINT = { x: 0.5, y: 0.5 };
|
|
18
|
+
/**
|
|
19
|
+
* Convert a pointer position over the preview's bounding box into a normalized
|
|
20
|
+
* focal point. A degenerate (zero-area) box falls back to centre rather than
|
|
21
|
+
* dividing by zero, so a not-yet-laid-out element never yields `NaN`.
|
|
22
|
+
*/
|
|
23
|
+
export function focalPointFromPoint(rect, clientX, clientY) {
|
|
24
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
25
|
+
return { ...CENTER_FOCAL_POINT };
|
|
26
|
+
return {
|
|
27
|
+
x: clampUnit((clientX - rect.left) / rect.width),
|
|
28
|
+
y: clampUnit((clientY - rect.top) / rect.height),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** CSS `object-position` for a focal point (percent), matching delivery (EC-230). */
|
|
32
|
+
export function focalPointObjectPosition(point) {
|
|
33
|
+
if (!point)
|
|
34
|
+
return undefined;
|
|
35
|
+
return `${clampUnit(point.x) * 100}% ${clampUnit(point.y) * 100}%`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The asset ids present in `after` but not in `before` — the records that an
|
|
39
|
+
* upload added to the library. The field uses this to auto-select an asset it
|
|
40
|
+
* just uploaded (the `upload()` API is fire-and-forget, so the new id is learned
|
|
41
|
+
* by diffing the library, not returned). Order follows `after`.
|
|
42
|
+
*/
|
|
43
|
+
export function newlyAddedAssetIds(before, after) {
|
|
44
|
+
const known = new Set(before);
|
|
45
|
+
return after.filter((id) => !known.has(id));
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=focal-point.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focal-point.js","sourceRoot":"","sources":["../../../src/components/assets/focal-point.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAQH,sDAAsD;AACtD,MAAM,UAAU,SAAS,CAAC,CAAS;IACjC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAA;IAC/B,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAClC,CAAC;AAED,yFAAyF;AACzF,MAAM,CAAC,MAAM,kBAAkB,GAAe,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAA;AAEhE;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAkE,EAClE,OAAe,EACf,OAAe;IAEf,IAAI,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,EAAE,GAAG,kBAAkB,EAAE,CAAA;IACzE,OAAO;QACL,CAAC,EAAE,SAAS,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC;QAChD,CAAC,EAAE,SAAS,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC;KACjD,CAAA;AACH,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,wBAAwB,CAAC,KAA6B;IACpE,IAAI,CAAC,KAAK;QAAE,OAAO,SAAS,CAAA;IAC5B,OAAO,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,CAAA;AACpE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAChC,MAAwB,EACxB,KAAwB;IAExB,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAA;IAC7B,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;AAC7C,CAAC","sourcesContent":["/**\n * Pure focal-point + upload-selection helpers for the image-field authoring UI\n * (EC-248). Kept side-effect-free and DOM-free so the coordinate math and the\n * \"which upload just landed\" reconciliation are unit-testable without a browser.\n *\n * A focal point is the EC-230 normalized 0–1 hotspot stored on the asset record\n * and threaded to delivery's `object-position`. The picker lets an editor place\n * it directly on the image preview.\n */\n\n/** A normalized focal point — both axes in `[0, 1]`. */\nexport interface FocalPoint {\n x: number\n y: number\n}\n\n/** Clamp a number into the unit interval `[0, 1]`. */\nexport function clampUnit(n: number): number {\n if (Number.isNaN(n)) return 0.5\n return n < 0 ? 0 : n > 1 ? 1 : n\n}\n\n/** The neutral default focal point (image centre) — what an unset hotspot renders as. */\nexport const CENTER_FOCAL_POINT: FocalPoint = { x: 0.5, y: 0.5 }\n\n/**\n * Convert a pointer position over the preview's bounding box into a normalized\n * focal point. A degenerate (zero-area) box falls back to centre rather than\n * dividing by zero, so a not-yet-laid-out element never yields `NaN`.\n */\nexport function focalPointFromPoint(\n rect: { left: number; top: number; width: number; height: number },\n clientX: number,\n clientY: number,\n): FocalPoint {\n if (rect.width <= 0 || rect.height <= 0) return { ...CENTER_FOCAL_POINT }\n return {\n x: clampUnit((clientX - rect.left) / rect.width),\n y: clampUnit((clientY - rect.top) / rect.height),\n }\n}\n\n/** CSS `object-position` for a focal point (percent), matching delivery (EC-230). */\nexport function focalPointObjectPosition(point: FocalPoint | undefined): string | undefined {\n if (!point) return undefined\n return `${clampUnit(point.x) * 100}% ${clampUnit(point.y) * 100}%`\n}\n\n/**\n * The asset ids present in `after` but not in `before` — the records that an\n * upload added to the library. The field uses this to auto-select an asset it\n * just uploaded (the `upload()` API is fire-and-forget, so the new id is learned\n * by diffing the library, not returned). Order follows `after`.\n */\nexport function newlyAddedAssetIds(\n before: Iterable<string>,\n after: readonly string[],\n): string[] {\n const known = new Set(before)\n return after.filter((id) => !known.has(id))\n}\n"]}
|
|
@@ -47,6 +47,18 @@ export type BuilderAction = {
|
|
|
47
47
|
id: string;
|
|
48
48
|
target: InsertTarget;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* EC-282 reuse gesture: replace a node with zero or more nodes at its
|
|
52
|
+
* position (clone-with-fresh-ids, like `insert`). Promote → 1 embed node;
|
|
53
|
+
* detach → the referenced element's inline blocks. One atomic structural
|
|
54
|
+
* edit, one undo step.
|
|
55
|
+
*/
|
|
56
|
+
| {
|
|
57
|
+
type: 'replace';
|
|
58
|
+
id: string;
|
|
59
|
+
nodes: ComponentNode[];
|
|
60
|
+
select?: boolean;
|
|
61
|
+
}
|
|
50
62
|
/**
|
|
51
63
|
* EC-162 layout gesture: wrap the reference node and the dragged subject in
|
|
52
64
|
* a two-column Grid container as ONE atomic structural edit (one undo step).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
3
|
-
import { cloneWithNewIds, duplicateNode, getNode, getParentInfo, insertNode, maxNodeIdSeq, moveNode, normalizeContainers, removeNode, removeNodeProp, reorderNode, setNodeProp, } from '@elytracms/core/project-graph';
|
|
3
|
+
import { cloneWithNewIds, duplicateNode, getNode, getParentInfo, insertNode, maxNodeIdSeq, moveNode, normalizeContainers, removeNode, removeNodeProp, reorderNode, replaceNode, setNodeProp, } from '@elytracms/core/project-graph';
|
|
4
4
|
import { applyWrap } from '@elytracms/studio/lib/dnd/drop-rules';
|
|
5
5
|
const HISTORY_LIMIT = 100;
|
|
6
6
|
function counter(start) {
|
|
@@ -106,6 +106,20 @@ export function builderReducer(state, action) {
|
|
|
106
106
|
return commit(state, reorderNode(root, action.id, action.delta));
|
|
107
107
|
case 'move':
|
|
108
108
|
return commit(state, moveNode(root, action.id, action.target));
|
|
109
|
+
case 'replace': {
|
|
110
|
+
// Mint fresh ids for the incoming nodes (page-local namespace) so a detached
|
|
111
|
+
// copy never collides with the page, and a promoted block's source subtree
|
|
112
|
+
// (kept for the new reusable document) stays untouched.
|
|
113
|
+
const c = counter(state.nodeSeq);
|
|
114
|
+
const cloned = action.nodes.map((n) => cloneWithNewIds(n, c.genId));
|
|
115
|
+
const next = replaceNode(root, action.id, cloned);
|
|
116
|
+
if (next === root)
|
|
117
|
+
return state; // root or absent id — no-op
|
|
118
|
+
return commit(state, next, {
|
|
119
|
+
nodeSeq: c.count,
|
|
120
|
+
selectedId: action.select === false ? state.selectedId : (cloned[0]?.id ?? state.selectedId),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
109
123
|
case 'wrap': {
|
|
110
124
|
// Layout gesture (EC-162): the reference and the dragged subject become
|
|
111
125
|
// the two columns of a new Grid container — ONE structural edit, one
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder-store.js","sourceRoot":"","sources":["../../../src/components/builder/builder-store.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport {\n cloneWithNewIds,\n duplicateNode,\n getNode,\n getParentInfo,\n insertNode,\n maxNodeIdSeq,\n moveNode,\n normalizeContainers,\n removeNode,\n removeNodeProp,\n reorderNode,\n setNodeProp,\n} from '@elytracms/core/project-graph'\nimport type { ComponentNode, InsertTarget, PropValue } from '@elytracms/core/project-graph'\nimport { applyWrap } from '@elytracms/studio/lib/dnd/drop-rules'\n\nconst HISTORY_LIMIT = 100\n\n/**\n * A canvas preview-width key (EC-250): `'full'` (unconstrained) or a configured\n * breakpoint name (`'sm'`, `'md'`, …). Dynamic because the set is sourced from\n * the consuming site's breakpoints, so it is a string, not a fixed union — the\n * switcher resolves the available options via {@link resolveCanvasViewports}.\n */\nexport type Viewport = string\n\n/**\n * The builder store's action union. EC-287 retired the DB-resident project graph\n * and its persistence mirror, so these actions are pure in-memory editor state —\n * no action maps to a persisted `graph.*` command anymore. The block-field editor\n * lifts committed edits into the CMS document via `onChange`\n * (`composition-field-editor`); nothing persists a graph.\n */\nexport type BuilderAction =\n | { type: 'select'; id: string | null }\n | { type: 'hover'; id: string | null }\n | { type: 'viewport'; viewport: Viewport }\n | { type: 'undo' }\n | { type: 'redo' }\n | { type: 'insert'; target: InsertTarget; node: ComponentNode; select?: boolean }\n | { type: 'remove'; id: string }\n | { type: 'duplicate'; id: string }\n | { type: 'reorder'; id: string; delta: -1 | 1 }\n | { type: 'move'; id: string; target: InsertTarget }\n /**\n * EC-162 layout gesture: wrap the reference node and the dragged subject in\n * a two-column Grid container as ONE atomic structural edit (one undo step).\n */\n | {\n type: 'wrap'\n referenceId: string\n side: 'left' | 'right'\n subject: { kind: 'node'; nodeId: string } | { kind: 'component'; node: ComponentNode }\n }\n | { type: 'setProp'; id: string; name: string; value: PropValue; coalesceKey?: string }\n | { type: 'removeProp'; id: string; name: string }\n\nexport interface BuilderState {\n /**\n * The edited composition: one root-bearing component tree (EC-287). The canvas\n * wraps a page document's `body` blocks into a synthetic root and edits it\n * directly — no page/layout wrapper. Undo/redo snapshot this root.\n */\n past: ComponentNode[]\n present: ComponentNode\n future: ComponentNode[]\n selectedId: string | null\n hoveredId: string | null\n viewport: Viewport\n /** Monotonic counter feeding fresh node ids. */\n nodeSeq: number\n /** Coalescing key of the last edit (consecutive same-key edits don't stack history). */\n lastCoalesceKey: string | null\n}\n\ntype Action = BuilderAction\n\ninterface Counter {\n genId: () => string\n count: number\n}\nfunction counter(start: number): Counter {\n const c: Counter = {\n count: start,\n genId: () => {\n c.count += 1\n return `node-${c.count}`\n },\n }\n return c\n}\n\n/**\n * Push a new graph onto history, coalescing consecutive edits with the same key.\n *\n * Every commit normalizes layout containers (EC-162 ledger fix): the\n * operations layer normalizes every persisted `graph.*` write (EC-161), so\n * the optimistic working graph must apply the same `normalizeContainers` pass\n * or the UI state diverges from the persisted revision after\n * container-emptying moves/removes. The mirrored command stays un-normalized\n * (`graphInvocationFor` sees the raw action); both sides normalize\n * identically, so they land on the same graph.\n */\nfunction commit(\n state: BuilderState,\n root: ComponentNode,\n opts: { coalesceKey?: string; nodeSeq?: number; selectedId?: string | null } = {},\n): BuilderState {\n const normalized = normalizeContainers(root)\n const present = normalized\n const coalesce = opts.coalesceKey != null && opts.coalesceKey === state.lastCoalesceKey\n // Normalization can dissolve the node a caller wanted selected (e.g. the\n // parent container of a removed child) — selection never points at a ghost.\n const desired = opts.selectedId !== undefined ? opts.selectedId : state.selectedId\n const selectedId = desired !== null && getNode(normalized, desired) ? desired : null\n return {\n ...state,\n past: coalesce ? state.past : [...state.past, state.present].slice(-HISTORY_LIMIT),\n present,\n future: [],\n nodeSeq: opts.nodeSeq ?? state.nodeSeq,\n lastCoalesceKey: opts.coalesceKey ?? null,\n selectedId,\n }\n}\n\n/**\n * The pure state transition. Exported (with `initialBuilderState`) for unit\n * tests — gesture determinism and normalization lockstep are reducer-level\n * properties, not render behavior.\n */\nexport function builderReducer(state: BuilderState, action: Action): BuilderState {\n const root = state.present\n switch (action.type) {\n case 'select':\n return { ...state, selectedId: action.id }\n case 'hover':\n return state.hoveredId === action.id ? state : { ...state, hoveredId: action.id }\n case 'viewport':\n return { ...state, viewport: action.viewport }\n\n case 'undo': {\n const prev = state.past.at(-1)\n if (!prev) return state\n return {\n ...state,\n past: state.past.slice(0, -1),\n present: prev,\n future: [state.present, ...state.future],\n lastCoalesceKey: null,\n selectedId: getNode(prev, state.selectedId ?? '') ? state.selectedId : null,\n }\n }\n case 'redo': {\n const next = state.future[0]\n if (!next) return state\n return {\n ...state,\n past: [...state.past, state.present],\n present: next,\n future: state.future.slice(1),\n lastCoalesceKey: null,\n selectedId: getNode(next, state.selectedId ?? '') ? state.selectedId : null,\n }\n }\n\n case 'insert': {\n const c = counter(state.nodeSeq)\n const node = cloneWithNewIds(action.node, c.genId)\n const next = insertNode(root, action.target, node)\n return commit(state, next, {\n nodeSeq: c.count,\n selectedId: action.select === false ? state.selectedId : node.id,\n })\n }\n case 'remove': {\n const parent = getParentInfo(root, action.id)\n const next = removeNode(root, action.id)\n const selectedId =\n state.selectedId === action.id ? (parent?.parent.id ?? null) : state.selectedId\n return commit(state, next, { selectedId })\n }\n case 'duplicate': {\n const c = counter(state.nodeSeq)\n const { root: next, newId } = duplicateNode(root, action.id, c.genId)\n return commit(state, next, { nodeSeq: c.count, selectedId: newId ?? state.selectedId })\n }\n case 'reorder':\n return commit(state, reorderNode(root, action.id, action.delta))\n case 'move':\n return commit(state, moveNode(root, action.id, action.target))\n case 'wrap': {\n // Layout gesture (EC-162): the reference and the dragged subject become\n // the two columns of a new Grid container — ONE structural edit, one\n // history entry, one mirrored command, so a single undo dissolves the\n // whole gesture.\n const c = counter(state.nodeSeq)\n const partner =\n action.subject.kind === 'node'\n ? { kind: 'node' as const, nodeId: action.subject.nodeId }\n : { kind: 'subtree' as const, node: cloneWithNewIds(action.subject.node, c.genId) }\n const containerId = c.genId()\n const wrapped = applyWrap({\n root,\n referenceId: action.referenceId,\n side: action.side,\n partner,\n containerId,\n })\n if (!wrapped) return state\n return commit(state, wrapped.root, { nodeSeq: c.count, selectedId: containerId })\n }\n case 'setProp':\n return commit(state, setNodeProp(root, action.id, action.name, action.value), {\n coalesceKey: action.coalesceKey,\n })\n case 'removeProp':\n return commit(state, removeNodeProp(root, action.id, action.name))\n default:\n return state\n }\n}\n\nexport interface BuilderStore extends BuilderState {\n canUndo: boolean\n canRedo: boolean\n selectedNode: ComponentNode | null\n dispatch: (action: Action) => void\n}\n\nconst BuilderContext = createContext<BuilderStore | null>(null)\n\nexport function initialBuilderState(composition: ComponentNode): BuilderState {\n // Start container-normal (idempotent on healthy trees) so the session and the\n // lifted baseline agree from the first edit.\n const present = normalizeContainers(composition)\n return {\n past: [],\n present,\n future: [],\n selectedId: present.id,\n hoveredId: null,\n viewport: 'full',\n // Seed past the ids the composition already carries (EC-203): a restored\n // tree holds minted `node-<n>` ids, so starting at 0 would re-mint `node-1`\n // on the first insert and produce a duplicate-node-id.\n nodeSeq: maxNodeIdSeq(present),\n lastCoalesceKey: null,\n }\n}\n\n/**\n * Undo/redo design (EC-141): the history stack is *UI state* — an in-memory\n * stack of composition-root snapshots. EC-287 retired the DB-resident project\n * graph and its append-only revision log, so the store no longer mirrors edits onto a\n * persistence layer: it is a pure in-memory editor. The block-field editor lifts\n * its committed composition into the open CMS document via `onChange`\n * (`composition-field-editor`); the document's own Save persists it as content.\n */\nexport function BuilderProvider({\n composition,\n children,\n}: {\n /** The composition root to edit (the composition-field editor's synthetic root). */\n composition: ComponentNode\n children: ReactNode\n}) {\n const [state, setState] = useState<BuilderState>(() => initialBuilderState(composition))\n const stateRef = useRef(state)\n\n // Plain-function dispatch (not `useReducer`) so each action sees the pre/post\n // state synchronously. EC-287: nothing persists a graph — committed edits are\n // lifted into the CMS document by the composition-field editor's `onChange`.\n const dispatch = useCallback((action: Action) => {\n const next = builderReducer(stateRef.current, action)\n stateRef.current = next\n setState(next)\n }, [])\n\n const store = useMemo<BuilderStore>(() => {\n const selectedNode = state.selectedId\n ? (getNode(state.present, state.selectedId) ?? null)\n : null\n return {\n ...state,\n canUndo: state.past.length > 0,\n canRedo: state.future.length > 0,\n selectedNode,\n dispatch,\n }\n }, [state, dispatch])\n\n return <BuilderContext.Provider value={store}>{children}</BuilderContext.Provider>\n}\n\nexport function useBuilder(): BuilderStore {\n const store = useContext(BuilderContext)\n if (!store) throw new Error('useBuilder must be used within a <BuilderProvider>.')\n return store\n}\n"]}
|
|
1
|
+
{"version":3,"file":"builder-store.js","sourceRoot":"","sources":["../../../src/components/builder/builder-store.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport {\n cloneWithNewIds,\n duplicateNode,\n getNode,\n getParentInfo,\n insertNode,\n maxNodeIdSeq,\n moveNode,\n normalizeContainers,\n removeNode,\n removeNodeProp,\n reorderNode,\n replaceNode,\n setNodeProp,\n} from '@elytracms/core/project-graph'\nimport type { ComponentNode, InsertTarget, PropValue } from '@elytracms/core/project-graph'\nimport { applyWrap } from '@elytracms/studio/lib/dnd/drop-rules'\n\nconst HISTORY_LIMIT = 100\n\n/**\n * A canvas preview-width key (EC-250): `'full'` (unconstrained) or a configured\n * breakpoint name (`'sm'`, `'md'`, …). Dynamic because the set is sourced from\n * the consuming site's breakpoints, so it is a string, not a fixed union — the\n * switcher resolves the available options via {@link resolveCanvasViewports}.\n */\nexport type Viewport = string\n\n/**\n * The builder store's action union. EC-287 retired the DB-resident project graph\n * and its persistence mirror, so these actions are pure in-memory editor state —\n * no action maps to a persisted `graph.*` command anymore. The block-field editor\n * lifts committed edits into the CMS document via `onChange`\n * (`composition-field-editor`); nothing persists a graph.\n */\nexport type BuilderAction =\n | { type: 'select'; id: string | null }\n | { type: 'hover'; id: string | null }\n | { type: 'viewport'; viewport: Viewport }\n | { type: 'undo' }\n | { type: 'redo' }\n | { type: 'insert'; target: InsertTarget; node: ComponentNode; select?: boolean }\n | { type: 'remove'; id: string }\n | { type: 'duplicate'; id: string }\n | { type: 'reorder'; id: string; delta: -1 | 1 }\n | { type: 'move'; id: string; target: InsertTarget }\n /**\n * EC-282 reuse gesture: replace a node with zero or more nodes at its\n * position (clone-with-fresh-ids, like `insert`). Promote → 1 embed node;\n * detach → the referenced element's inline blocks. One atomic structural\n * edit, one undo step.\n */\n | { type: 'replace'; id: string; nodes: ComponentNode[]; select?: boolean }\n /**\n * EC-162 layout gesture: wrap the reference node and the dragged subject in\n * a two-column Grid container as ONE atomic structural edit (one undo step).\n */\n | {\n type: 'wrap'\n referenceId: string\n side: 'left' | 'right'\n subject: { kind: 'node'; nodeId: string } | { kind: 'component'; node: ComponentNode }\n }\n | { type: 'setProp'; id: string; name: string; value: PropValue; coalesceKey?: string }\n | { type: 'removeProp'; id: string; name: string }\n\nexport interface BuilderState {\n /**\n * The edited composition: one root-bearing component tree (EC-287). The canvas\n * wraps a page document's `body` blocks into a synthetic root and edits it\n * directly — no page/layout wrapper. Undo/redo snapshot this root.\n */\n past: ComponentNode[]\n present: ComponentNode\n future: ComponentNode[]\n selectedId: string | null\n hoveredId: string | null\n viewport: Viewport\n /** Monotonic counter feeding fresh node ids. */\n nodeSeq: number\n /** Coalescing key of the last edit (consecutive same-key edits don't stack history). */\n lastCoalesceKey: string | null\n}\n\ntype Action = BuilderAction\n\ninterface Counter {\n genId: () => string\n count: number\n}\nfunction counter(start: number): Counter {\n const c: Counter = {\n count: start,\n genId: () => {\n c.count += 1\n return `node-${c.count}`\n },\n }\n return c\n}\n\n/**\n * Push a new graph onto history, coalescing consecutive edits with the same key.\n *\n * Every commit normalizes layout containers (EC-162 ledger fix): the\n * operations layer normalizes every persisted `graph.*` write (EC-161), so\n * the optimistic working graph must apply the same `normalizeContainers` pass\n * or the UI state diverges from the persisted revision after\n * container-emptying moves/removes. The mirrored command stays un-normalized\n * (`graphInvocationFor` sees the raw action); both sides normalize\n * identically, so they land on the same graph.\n */\nfunction commit(\n state: BuilderState,\n root: ComponentNode,\n opts: { coalesceKey?: string; nodeSeq?: number; selectedId?: string | null } = {},\n): BuilderState {\n const normalized = normalizeContainers(root)\n const present = normalized\n const coalesce = opts.coalesceKey != null && opts.coalesceKey === state.lastCoalesceKey\n // Normalization can dissolve the node a caller wanted selected (e.g. the\n // parent container of a removed child) — selection never points at a ghost.\n const desired = opts.selectedId !== undefined ? opts.selectedId : state.selectedId\n const selectedId = desired !== null && getNode(normalized, desired) ? desired : null\n return {\n ...state,\n past: coalesce ? state.past : [...state.past, state.present].slice(-HISTORY_LIMIT),\n present,\n future: [],\n nodeSeq: opts.nodeSeq ?? state.nodeSeq,\n lastCoalesceKey: opts.coalesceKey ?? null,\n selectedId,\n }\n}\n\n/**\n * The pure state transition. Exported (with `initialBuilderState`) for unit\n * tests — gesture determinism and normalization lockstep are reducer-level\n * properties, not render behavior.\n */\nexport function builderReducer(state: BuilderState, action: Action): BuilderState {\n const root = state.present\n switch (action.type) {\n case 'select':\n return { ...state, selectedId: action.id }\n case 'hover':\n return state.hoveredId === action.id ? state : { ...state, hoveredId: action.id }\n case 'viewport':\n return { ...state, viewport: action.viewport }\n\n case 'undo': {\n const prev = state.past.at(-1)\n if (!prev) return state\n return {\n ...state,\n past: state.past.slice(0, -1),\n present: prev,\n future: [state.present, ...state.future],\n lastCoalesceKey: null,\n selectedId: getNode(prev, state.selectedId ?? '') ? state.selectedId : null,\n }\n }\n case 'redo': {\n const next = state.future[0]\n if (!next) return state\n return {\n ...state,\n past: [...state.past, state.present],\n present: next,\n future: state.future.slice(1),\n lastCoalesceKey: null,\n selectedId: getNode(next, state.selectedId ?? '') ? state.selectedId : null,\n }\n }\n\n case 'insert': {\n const c = counter(state.nodeSeq)\n const node = cloneWithNewIds(action.node, c.genId)\n const next = insertNode(root, action.target, node)\n return commit(state, next, {\n nodeSeq: c.count,\n selectedId: action.select === false ? state.selectedId : node.id,\n })\n }\n case 'remove': {\n const parent = getParentInfo(root, action.id)\n const next = removeNode(root, action.id)\n const selectedId =\n state.selectedId === action.id ? (parent?.parent.id ?? null) : state.selectedId\n return commit(state, next, { selectedId })\n }\n case 'duplicate': {\n const c = counter(state.nodeSeq)\n const { root: next, newId } = duplicateNode(root, action.id, c.genId)\n return commit(state, next, { nodeSeq: c.count, selectedId: newId ?? state.selectedId })\n }\n case 'reorder':\n return commit(state, reorderNode(root, action.id, action.delta))\n case 'move':\n return commit(state, moveNode(root, action.id, action.target))\n case 'replace': {\n // Mint fresh ids for the incoming nodes (page-local namespace) so a detached\n // copy never collides with the page, and a promoted block's source subtree\n // (kept for the new reusable document) stays untouched.\n const c = counter(state.nodeSeq)\n const cloned = action.nodes.map((n) => cloneWithNewIds(n, c.genId))\n const next = replaceNode(root, action.id, cloned)\n if (next === root) return state // root or absent id — no-op\n return commit(state, next, {\n nodeSeq: c.count,\n selectedId: action.select === false ? state.selectedId : (cloned[0]?.id ?? state.selectedId),\n })\n }\n case 'wrap': {\n // Layout gesture (EC-162): the reference and the dragged subject become\n // the two columns of a new Grid container — ONE structural edit, one\n // history entry, one mirrored command, so a single undo dissolves the\n // whole gesture.\n const c = counter(state.nodeSeq)\n const partner =\n action.subject.kind === 'node'\n ? { kind: 'node' as const, nodeId: action.subject.nodeId }\n : { kind: 'subtree' as const, node: cloneWithNewIds(action.subject.node, c.genId) }\n const containerId = c.genId()\n const wrapped = applyWrap({\n root,\n referenceId: action.referenceId,\n side: action.side,\n partner,\n containerId,\n })\n if (!wrapped) return state\n return commit(state, wrapped.root, { nodeSeq: c.count, selectedId: containerId })\n }\n case 'setProp':\n return commit(state, setNodeProp(root, action.id, action.name, action.value), {\n coalesceKey: action.coalesceKey,\n })\n case 'removeProp':\n return commit(state, removeNodeProp(root, action.id, action.name))\n default:\n return state\n }\n}\n\nexport interface BuilderStore extends BuilderState {\n canUndo: boolean\n canRedo: boolean\n selectedNode: ComponentNode | null\n dispatch: (action: Action) => void\n}\n\nconst BuilderContext = createContext<BuilderStore | null>(null)\n\nexport function initialBuilderState(composition: ComponentNode): BuilderState {\n // Start container-normal (idempotent on healthy trees) so the session and the\n // lifted baseline agree from the first edit.\n const present = normalizeContainers(composition)\n return {\n past: [],\n present,\n future: [],\n selectedId: present.id,\n hoveredId: null,\n viewport: 'full',\n // Seed past the ids the composition already carries (EC-203): a restored\n // tree holds minted `node-<n>` ids, so starting at 0 would re-mint `node-1`\n // on the first insert and produce a duplicate-node-id.\n nodeSeq: maxNodeIdSeq(present),\n lastCoalesceKey: null,\n }\n}\n\n/**\n * Undo/redo design (EC-141): the history stack is *UI state* — an in-memory\n * stack of composition-root snapshots. EC-287 retired the DB-resident project\n * graph and its append-only revision log, so the store no longer mirrors edits onto a\n * persistence layer: it is a pure in-memory editor. The block-field editor lifts\n * its committed composition into the open CMS document via `onChange`\n * (`composition-field-editor`); the document's own Save persists it as content.\n */\nexport function BuilderProvider({\n composition,\n children,\n}: {\n /** The composition root to edit (the composition-field editor's synthetic root). */\n composition: ComponentNode\n children: ReactNode\n}) {\n const [state, setState] = useState<BuilderState>(() => initialBuilderState(composition))\n const stateRef = useRef(state)\n\n // Plain-function dispatch (not `useReducer`) so each action sees the pre/post\n // state synchronously. EC-287: nothing persists a graph — committed edits are\n // lifted into the CMS document by the composition-field editor's `onChange`.\n const dispatch = useCallback((action: Action) => {\n const next = builderReducer(stateRef.current, action)\n stateRef.current = next\n setState(next)\n }, [])\n\n const store = useMemo<BuilderStore>(() => {\n const selectedNode = state.selectedId\n ? (getNode(state.present, state.selectedId) ?? null)\n : null\n return {\n ...state,\n canUndo: state.past.length > 0,\n canRedo: state.future.length > 0,\n selectedNode,\n dispatch,\n }\n }, [state, dispatch])\n\n return <BuilderContext.Provider value={store}>{children}</BuilderContext.Provider>\n}\n\nexport function useBuilder(): BuilderStore {\n const store = useContext(BuilderContext)\n if (!store) throw new Error('useBuilder must be used within a <BuilderProvider>.')\n return store\n}\n"]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CanvasPerspective } from '@elytracms/studio/lib/data';
|
|
1
2
|
/**
|
|
2
3
|
* Iframe-isolated page preview (brief §8.2, EC-061, EC-064). The graph renders
|
|
3
4
|
* into a same-origin iframe via a React portal — true style isolation + true
|
|
@@ -5,4 +6,9 @@
|
|
|
5
6
|
* select and hover/selection draw outlines. Native listeners bridge the iframe
|
|
6
7
|
* boundary (React's synthetic events don't cross frames).
|
|
7
8
|
*/
|
|
8
|
-
export declare function BuilderCanvas(
|
|
9
|
+
export declare function BuilderCanvas({ perspective, }?: {
|
|
10
|
+
/** Resolve references against this perspective (EC-300/AD-16). The editable Draft
|
|
11
|
+
* view passes `'draft'` (a just-made reusable shows immediately); the read-only
|
|
12
|
+
* Published view passes `'published'` (honest preview of what delivery serves). */
|
|
13
|
+
perspective?: CanvasPerspective;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -14,6 +14,8 @@ import { cmsBindingFallback, useStudioLocale } from '@elytracms/studio/lib/local
|
|
|
14
14
|
import { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session';
|
|
15
15
|
import { getCanvasStylesheetUrl } from './canvas-stylesheet';
|
|
16
16
|
import { useBuilder } from './builder-store';
|
|
17
|
+
import { useReuse } from './reuse-context';
|
|
18
|
+
import { reuseEmbedRef } from './reuse';
|
|
17
19
|
import { useCanvasViewports, viewportWidth } from './canvas-breakpoints';
|
|
18
20
|
import { ClipboardNoticeOverlay, attachClipboardListeners, useStudioClipboard, } from './clipboard-actions';
|
|
19
21
|
import { createInlineEditSession, createInlineRichTextSession, findInlineTextHost, resolveInlineEditableProp, resolveInlineRichTextProp, } from './inline-edit';
|
|
@@ -108,7 +110,7 @@ function collectIds(node, out = new Set()) {
|
|
|
108
110
|
* select and hover/selection draw outlines. Native listeners bridge the iframe
|
|
109
111
|
* boundary (React's synthetic events don't cross frames).
|
|
110
112
|
*/
|
|
111
|
-
export function BuilderCanvas() {
|
|
113
|
+
export function BuilderCanvas({ perspective = 'draft', } = {}) {
|
|
112
114
|
const builder = useBuilder();
|
|
113
115
|
const { dispatch } = builder;
|
|
114
116
|
const studio = getStudioRegistry();
|
|
@@ -640,24 +642,44 @@ export function BuilderCanvas() {
|
|
|
640
642
|
// the value actually came from. Same decision function as the inspector's
|
|
641
643
|
// Content tab (`cmsBindingFallback`), so chip and label can never disagree.
|
|
642
644
|
const { locale: activeLocale } = useStudioLocale();
|
|
645
|
+
// EC-300: the reuse capability identifies a transcluding embed + its relation prop,
|
|
646
|
+
// so the canvas can detect a HARD reference whose target doesn't resolve and show a
|
|
647
|
+
// placeholder instead of an empty section. Null-safe (no provider / no capability).
|
|
648
|
+
const reuse = useReuse();
|
|
643
649
|
// EC-254: populate a block's `relation` prop (the editor's document pick — a
|
|
644
650
|
// ProductGrid's products, a Reusable's element) to delivery shape from the
|
|
645
651
|
// active content session, in the canvas's active locale, so the canvas previews
|
|
646
652
|
// picked documents exactly as the host renders them. The studio analog of the
|
|
647
653
|
// host's baked relations map. A missing/invisible target → null → the block's
|
|
648
654
|
// own empty state.
|
|
649
|
-
const resolveRelation = useCallback((ref) => resolveCanvasRelation(ref, activeLocale), [activeLocale]);
|
|
655
|
+
const resolveRelation = useCallback((ref) => resolveCanvasRelation(ref, activeLocale, perspective), [activeLocale, perspective]);
|
|
650
656
|
// EC-255: resolve a listing block's query (a ProductGrid keyed to a category) to
|
|
651
657
|
// its documents from the active content session, in the canvas's active locale, so
|
|
652
658
|
// the canvas previews a dynamic grid exactly as the host renders it. The studio
|
|
653
659
|
// analog of the host's baked listings map; an empty/invalid query → [] → the
|
|
654
660
|
// block's own empty state.
|
|
655
|
-
const resolveListing = useCallback((query) => resolveCanvasListing(query, activeLocale), [activeLocale]);
|
|
661
|
+
const resolveListing = useCallback((query) => resolveCanvasListing(query, activeLocale, perspective), [activeLocale, perspective]);
|
|
656
662
|
// EC-291: resolve a rich-text internal link (`{type:'reference'}`) to its target's
|
|
657
663
|
// URL path from the active content session, in the canvas's active locale — the
|
|
658
664
|
// studio analog of the host's baked links map. A missing/unpublished/unroutable
|
|
659
665
|
// target → null → the rich-text renderer shows the visible `unresolved-ref` issue.
|
|
660
|
-
const resolveLink = useCallback((ref) => resolveCanvasLink(ref, activeLocale), [activeLocale]);
|
|
666
|
+
const resolveLink = useCallback((ref) => resolveCanvasLink(ref, activeLocale, perspective), [activeLocale, perspective]);
|
|
667
|
+
// EC-300/AD-16: a transcluding embed whose target doesn't resolve in the active
|
|
668
|
+
// perspective renders a placeholder, never an empty section. `unpublished` = the
|
|
669
|
+
// target exists as a draft but isn't live (Published view of a draft-only reusable);
|
|
670
|
+
// `missing` = no resolvable target at all (deleted). `null` = resolves fine, or the
|
|
671
|
+
// node isn't an embed / has no target picked yet (its own empty state owns that).
|
|
672
|
+
const unresolvedReuse = useCallback((node) => {
|
|
673
|
+
const cap = reuse?.capability;
|
|
674
|
+
if (!cap)
|
|
675
|
+
return null;
|
|
676
|
+
const ref = reuseEmbedRef(node, cap);
|
|
677
|
+
if (!ref)
|
|
678
|
+
return null;
|
|
679
|
+
if (resolveCanvasRelation(ref, activeLocale, perspective))
|
|
680
|
+
return null;
|
|
681
|
+
return resolveCanvasRelation(ref, activeLocale, 'draft') ? 'unpublished' : 'missing';
|
|
682
|
+
}, [reuse, activeLocale, perspective]);
|
|
661
683
|
const localeFallbackOf = useCallback((node) => {
|
|
662
684
|
for (const value of Object.values(node.props ?? {})) {
|
|
663
685
|
if (value.kind !== 'binding')
|
|
@@ -676,8 +698,16 @@ export function BuilderCanvas() {
|
|
|
676
698
|
resolveRelation,
|
|
677
699
|
resolveListing,
|
|
678
700
|
resolveLink,
|
|
679
|
-
decorateNode: (node, rendered) =>
|
|
680
|
-
|
|
701
|
+
decorateNode: (node, rendered) => {
|
|
702
|
+
if (!pageNodeIds.has(node.id))
|
|
703
|
+
return rendered;
|
|
704
|
+
// An unresolved hard reference shows the placeholder in place of the empty
|
|
705
|
+
// transclusion; the node stays selectable, so its inspector relation chip
|
|
706
|
+
// (EC-296) deep-links to the reusable to edit/publish it.
|
|
707
|
+
const unresolved = unresolvedReuse(node);
|
|
708
|
+
return (_jsx("div", { "data-ec-node-id": node.id, className: "ec-editable", "data-selected": node.id === builder.selectedId || undefined, "data-hovered": node.id === builder.hoveredId || undefined, "data-ec-locale-fallback": localeFallbackOf(node), children: unresolved ? _jsx(UnresolvedReusablePlaceholder, { status: unresolved }) : rendered }, `${node.id}#${richEditEpochs[node.id] ?? 0}`));
|
|
709
|
+
},
|
|
710
|
+
}), [studio, resolveBinding, resolveAsset, resolveRelation, resolveListing, resolveLink, unresolvedReuse, pageNodeIds, builder.selectedId, builder.hoveredId, localeFallbackOf, richEditEpochs]);
|
|
681
711
|
// Tokenized spacing handles (EC-163): a selected Stack/Grid with 2+ children
|
|
682
712
|
// gets draggable gap handles between them, portaled into the iframe.
|
|
683
713
|
const spacingTarget = doc && hasSpacingHandles(builder.selectedNode) && pageNodeIds.has(builder.selectedNode.id)
|
|
@@ -694,4 +724,31 @@ export function BuilderCanvas() {
|
|
|
694
724
|
renderPageComposition(activeRoot, ctx), doc.body)
|
|
695
725
|
: null, spacingTarget && doc ? (_jsx(SpacingHandles, { doc: doc, node: spacingTarget }, spacingTarget.id)) : null, richEditHost && doc ? _jsx(RichTextToolbar, { doc: doc, host: richEditHost }) : null] })] }));
|
|
696
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* In-canvas placeholder for a transcluding embed whose target doesn't resolve
|
|
729
|
+
* (EC-300/AD-16) — shown in place of the empty section so a hard reference never
|
|
730
|
+
* silently vanishes. Rendered INTO the iframe (portal), which carries only the
|
|
731
|
+
* project's stylesheet, so it uses inline styles rather than studio UI classes.
|
|
732
|
+
* The node stays selectable; the inspector's `element` relation chip (EC-296)
|
|
733
|
+
* deep-links to the reusable to edit or publish it.
|
|
734
|
+
*/
|
|
735
|
+
function UnresolvedReusablePlaceholder({ status }) {
|
|
736
|
+
const title = status === 'unpublished'
|
|
737
|
+
? 'This reusable element isn’t published yet'
|
|
738
|
+
: 'This reusable element is missing';
|
|
739
|
+
const hint = status === 'unpublished'
|
|
740
|
+
? 'Publish it to show it here and on live pages — or open it from the Properties panel to keep editing.'
|
|
741
|
+
: 'Open the embed’s “Element” in the Properties panel to fix the reference.';
|
|
742
|
+
return (_jsxs("div", { style: {
|
|
743
|
+
boxSizing: 'border-box',
|
|
744
|
+
border: '1px dashed rgba(120,120,120,0.5)',
|
|
745
|
+
borderRadius: 8,
|
|
746
|
+
padding: '24px 28px',
|
|
747
|
+
margin: '12px 16px',
|
|
748
|
+
background: 'rgba(127,127,127,0.06)',
|
|
749
|
+
color: 'rgba(90,90,90,0.95)',
|
|
750
|
+
font: '14px/1.5 system-ui, -apple-system, sans-serif',
|
|
751
|
+
textAlign: 'center',
|
|
752
|
+
}, children: [_jsxs("div", { style: { fontWeight: 600 }, children: ["\u26A0 ", title] }), _jsx("div", { style: { marginTop: 6, fontSize: 13, opacity: 0.85 }, children: hint })] }));
|
|
753
|
+
}
|
|
697
754
|
//# sourceMappingURL=canvas.js.map
|