@blocklify/preview 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/block-path.d.ts +40 -0
- package/dist/block-path.js +169 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/preview-styles.d.ts +8 -0
- package/dist/preview-styles.js +212 -0
- package/dist/preview.d.ts +36 -31
- package/dist/preview.js +118 -109
- package/dist/sortable-preview.d.ts +17 -0
- package/dist/sortable-preview.js +213 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -72,3 +72,20 @@ const blockMap = createStarterBlockMap({
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Use `blockMap` with `BlocklifyPreview` on `/preview`, and `BlocklifyEntryView` for the public page.
|
|
75
|
+
|
|
76
|
+
## Live preview
|
|
77
|
+
|
|
78
|
+
`BlocklifyPreview` listens for draft content from the Blocklify editor. In that iframe, each block gets a dashed outline. Clicking a block focuses it in the editor. A drag handle reorders body blocks and nested `columns` / `items` cells, and the editor saves that order.
|
|
79
|
+
|
|
80
|
+
Spread `BlocklifyEditable` on the root element of each block component. For a columns or page block, call `renderNestedBlocks` when it is passed so the cells can be reordered:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
export function Columns({ block, BlocklifyEditable = {}, blockPath = [], renderNestedBlocks }: BlocklifyBlockProps) {
|
|
84
|
+
const items = Array.isArray(block.columns) ? block.columns : [];
|
|
85
|
+
return (
|
|
86
|
+
<section {...BlocklifyEditable}>
|
|
87
|
+
{renderNestedBlocks ? renderNestedBlocks(items, blockPath, "columns") : null}
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type BlockPathSegment = {
|
|
2
|
+
field: string;
|
|
3
|
+
index: number;
|
|
4
|
+
};
|
|
5
|
+
export type BlockPath = BlockPathSegment[];
|
|
6
|
+
export declare const DEFAULT_SORTABLE_BLOCKS_FIELDS: readonly ["body", "blocks", "columns", "items"];
|
|
7
|
+
export declare function getPathKey(path: BlockPath): string;
|
|
8
|
+
export declare function getSortableId(block: {
|
|
9
|
+
type: string;
|
|
10
|
+
_uid?: string;
|
|
11
|
+
}, blockPath: BlockPath): string;
|
|
12
|
+
export declare function isSortableBlocksField(fieldName: string, sortableFields: ReadonlySet<string>): boolean;
|
|
13
|
+
export declare function toBlockPathSegment(parentPath: BlockPath, fieldName: string, index: number): BlockPath;
|
|
14
|
+
export declare function createSortableFieldsSet(fields?: readonly string[]): ReadonlySet<string>;
|
|
15
|
+
export declare function getSortableContextId(parentPath: BlockPath, fieldName: string): string;
|
|
16
|
+
export declare function getBlocksFieldNameFromPath(path: BlockPath): string;
|
|
17
|
+
export declare function getBlocksListKey(blockPath: BlockPath): string;
|
|
18
|
+
export declare function isSameBlocksList(sourcePath: BlockPath, destinationPath: BlockPath): boolean;
|
|
19
|
+
export declare function adjustDestinationPathAfterRemoval(sourcePath: BlockPath, destinationPath: BlockPath): BlockPath;
|
|
20
|
+
export declare function isGridSortableField(fieldName: string): boolean;
|
|
21
|
+
export declare function isNestedSortableContainer(type: string): boolean;
|
|
22
|
+
export declare function isRootOnlyBlockType(type: string): boolean;
|
|
23
|
+
export declare function canMoveBlockToDestination(blockType: string | undefined, sourcePath: BlockPath, destinationPath: BlockPath): boolean;
|
|
24
|
+
export declare function isNestedGridDropPath(path: BlockPath): boolean;
|
|
25
|
+
/** True when the destination path is inside the block being dragged. */
|
|
26
|
+
export declare function isDropIntoOwnNestedField(sourcePath: BlockPath, destinationPath: BlockPath): boolean;
|
|
27
|
+
export declare function isPathInsideBlock(path: BlockPath, blockPath: BlockPath): boolean;
|
|
28
|
+
export declare function isVirtualPreviewBlock(block: {
|
|
29
|
+
_uid?: string;
|
|
30
|
+
}): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Move a block inside draft content.
|
|
33
|
+
* Same-list inserts at the hovered index after removal (arrayMove).
|
|
34
|
+
* Cross-list shifts ancestor indexes that sat after the removed source.
|
|
35
|
+
* Returns null when the path or uid does not match.
|
|
36
|
+
*/
|
|
37
|
+
export declare function moveBlockInContent(content: Record<string, unknown>, sourcePath: BlockPath, destinationPath: BlockPath): {
|
|
38
|
+
next: Record<string, unknown>;
|
|
39
|
+
movedUid: string;
|
|
40
|
+
} | null;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export const DEFAULT_SORTABLE_BLOCKS_FIELDS = ["body", "blocks", "columns", "items"];
|
|
2
|
+
export function getPathKey(path) {
|
|
3
|
+
if (path.length === 0)
|
|
4
|
+
return "root";
|
|
5
|
+
return path.map((segment) => `${segment.field}:${segment.index}`).join("/");
|
|
6
|
+
}
|
|
7
|
+
export function getSortableId(block, blockPath) {
|
|
8
|
+
if (block._uid)
|
|
9
|
+
return block._uid;
|
|
10
|
+
if (blockPath.length === 0)
|
|
11
|
+
return `orphan:${block.type}`;
|
|
12
|
+
const parentPath = blockPath.slice(0, -1);
|
|
13
|
+
const leaf = blockPath[blockPath.length - 1];
|
|
14
|
+
return `${getPathKey(parentPath)}:${leaf.field}:${leaf.index}`;
|
|
15
|
+
}
|
|
16
|
+
export function isSortableBlocksField(fieldName, sortableFields) {
|
|
17
|
+
return sortableFields.has(fieldName);
|
|
18
|
+
}
|
|
19
|
+
export function toBlockPathSegment(parentPath, fieldName, index) {
|
|
20
|
+
return [...parentPath, { field: fieldName, index }];
|
|
21
|
+
}
|
|
22
|
+
export function createSortableFieldsSet(fields) {
|
|
23
|
+
return new Set(fields ?? DEFAULT_SORTABLE_BLOCKS_FIELDS);
|
|
24
|
+
}
|
|
25
|
+
export function getSortableContextId(parentPath, fieldName) {
|
|
26
|
+
return `${getPathKey(parentPath)}:${fieldName}`;
|
|
27
|
+
}
|
|
28
|
+
export function getBlocksFieldNameFromPath(path) {
|
|
29
|
+
if (path.length === 0)
|
|
30
|
+
return "body";
|
|
31
|
+
return path[path.length - 1].field;
|
|
32
|
+
}
|
|
33
|
+
function getListKeyForSegment(path, segmentIndex) {
|
|
34
|
+
const segment = path[segmentIndex];
|
|
35
|
+
const parentPath = path.slice(0, segmentIndex);
|
|
36
|
+
return getSortableContextId(parentPath, segment.field);
|
|
37
|
+
}
|
|
38
|
+
export function getBlocksListKey(blockPath) {
|
|
39
|
+
if (blockPath.length === 0) {
|
|
40
|
+
return getSortableContextId([], "body");
|
|
41
|
+
}
|
|
42
|
+
const leaf = blockPath[blockPath.length - 1];
|
|
43
|
+
const parentPath = blockPath.slice(0, -1);
|
|
44
|
+
return getSortableContextId(parentPath, leaf.field);
|
|
45
|
+
}
|
|
46
|
+
export function isSameBlocksList(sourcePath, destinationPath) {
|
|
47
|
+
return getBlocksListKey(sourcePath) === getBlocksListKey(destinationPath);
|
|
48
|
+
}
|
|
49
|
+
export function adjustDestinationPathAfterRemoval(sourcePath, destinationPath) {
|
|
50
|
+
const sourceListKey = getBlocksListKey(sourcePath);
|
|
51
|
+
const sourceIndex = sourcePath[sourcePath.length - 1].index;
|
|
52
|
+
return destinationPath.map((segment, segmentIndex) => {
|
|
53
|
+
const listKey = getListKeyForSegment(destinationPath, segmentIndex);
|
|
54
|
+
if (listKey === sourceListKey && segment.index > sourceIndex) {
|
|
55
|
+
return { ...segment, index: segment.index - 1 };
|
|
56
|
+
}
|
|
57
|
+
return segment;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function isGridSortableField(fieldName) {
|
|
61
|
+
return fieldName === "columns" || fieldName === "items";
|
|
62
|
+
}
|
|
63
|
+
const NESTED_SORTABLE_CONTAINER_TYPES = new Set(["columns", "page"]);
|
|
64
|
+
/** Block types that stay on the page body and are not dropped into columns or items. */
|
|
65
|
+
const ROOT_ONLY_BLOCK_TYPES = new Set(["hero", "columns", "page"]);
|
|
66
|
+
export function isNestedSortableContainer(type) {
|
|
67
|
+
return NESTED_SORTABLE_CONTAINER_TYPES.has(type.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
export function isRootOnlyBlockType(type) {
|
|
70
|
+
return ROOT_ONLY_BLOCK_TYPES.has(type.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
export function canMoveBlockToDestination(blockType, sourcePath, destinationPath) {
|
|
73
|
+
const sourceField = getBlocksFieldNameFromPath(sourcePath);
|
|
74
|
+
if (isGridSortableField(sourceField)) {
|
|
75
|
+
return isSameBlocksList(sourcePath, destinationPath);
|
|
76
|
+
}
|
|
77
|
+
if (!blockType)
|
|
78
|
+
return true;
|
|
79
|
+
const destField = getBlocksFieldNameFromPath(destinationPath);
|
|
80
|
+
if (!isGridSortableField(destField))
|
|
81
|
+
return true;
|
|
82
|
+
return !isRootOnlyBlockType(blockType);
|
|
83
|
+
}
|
|
84
|
+
export function isNestedGridDropPath(path) {
|
|
85
|
+
if (path.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
return isGridSortableField(path[path.length - 1].field);
|
|
88
|
+
}
|
|
89
|
+
/** True when the destination path is inside the block being dragged. */
|
|
90
|
+
export function isDropIntoOwnNestedField(sourcePath, destinationPath) {
|
|
91
|
+
if (destinationPath.length <= sourcePath.length)
|
|
92
|
+
return false;
|
|
93
|
+
for (let i = 0; i < sourcePath.length; i += 1) {
|
|
94
|
+
if (sourcePath[i].field !== destinationPath[i].field ||
|
|
95
|
+
sourcePath[i].index !== destinationPath[i].index) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
export function isPathInsideBlock(path, blockPath) {
|
|
102
|
+
if (path.length <= blockPath.length)
|
|
103
|
+
return false;
|
|
104
|
+
for (let i = 0; i < blockPath.length; i += 1) {
|
|
105
|
+
if (path[i].field !== blockPath[i].field || path[i].index !== blockPath[i].index) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
export function isVirtualPreviewBlock(block) {
|
|
112
|
+
return typeof block._uid === "string" && block._uid.startsWith("virtual-");
|
|
113
|
+
}
|
|
114
|
+
function getParentArray(content, path) {
|
|
115
|
+
if (path.length === 0)
|
|
116
|
+
return null;
|
|
117
|
+
let current = content;
|
|
118
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
119
|
+
const segment = path[i];
|
|
120
|
+
if (!current || typeof current !== "object")
|
|
121
|
+
return null;
|
|
122
|
+
const container = current[segment.field];
|
|
123
|
+
if (!Array.isArray(container) || segment.index < 0 || segment.index >= container.length) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
current = container[segment.index];
|
|
127
|
+
}
|
|
128
|
+
if (!current || typeof current !== "object")
|
|
129
|
+
return null;
|
|
130
|
+
const leaf = path[path.length - 1];
|
|
131
|
+
const parentArray = current[leaf.field];
|
|
132
|
+
return Array.isArray(parentArray)
|
|
133
|
+
? parentArray
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Move a block inside draft content.
|
|
138
|
+
* Same-list inserts at the hovered index after removal (arrayMove).
|
|
139
|
+
* Cross-list shifts ancestor indexes that sat after the removed source.
|
|
140
|
+
* Returns null when the path or uid does not match.
|
|
141
|
+
*/
|
|
142
|
+
export function moveBlockInContent(content, sourcePath, destinationPath) {
|
|
143
|
+
if (sourcePath.length === 0 || destinationPath.length === 0)
|
|
144
|
+
return null;
|
|
145
|
+
const next = structuredClone(content);
|
|
146
|
+
const sourceParent = getParentArray(next, sourcePath);
|
|
147
|
+
if (!sourceParent)
|
|
148
|
+
return null;
|
|
149
|
+
const sourceLeaf = sourcePath[sourcePath.length - 1];
|
|
150
|
+
const sourceBlock = sourceParent[sourceLeaf.index];
|
|
151
|
+
if (!sourceBlock || typeof sourceBlock !== "object")
|
|
152
|
+
return null;
|
|
153
|
+
const movedUid = sourceBlock._uid;
|
|
154
|
+
if (typeof movedUid !== "string" || movedUid === "")
|
|
155
|
+
return null;
|
|
156
|
+
const [movedBlock] = sourceParent.splice(sourceLeaf.index, 1);
|
|
157
|
+
if (!movedBlock)
|
|
158
|
+
return null;
|
|
159
|
+
const effectiveDestination = isSameBlocksList(sourcePath, destinationPath)
|
|
160
|
+
? destinationPath
|
|
161
|
+
: adjustDestinationPathAfterRemoval(sourcePath, destinationPath);
|
|
162
|
+
const targetParent = getParentArray(next, effectiveDestination);
|
|
163
|
+
if (!targetParent)
|
|
164
|
+
return null;
|
|
165
|
+
const targetLeaf = effectiveDestination[effectiveDestination.length - 1];
|
|
166
|
+
const clampedIndex = Math.max(0, Math.min(targetLeaf.index, targetParent.length));
|
|
167
|
+
targetParent.splice(clampedIndex, 0, movedBlock);
|
|
168
|
+
return { next, movedUid };
|
|
169
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
* } from "@blocklify/preview";
|
|
10
10
|
*/
|
|
11
11
|
export { createBlocklifyClient, type BlocklifyClientConfig, type BlocklifyComponent, type BlocklifyEntry, type BlocklifySiteAsset, type BlocklifySiteSettings, } from "./client.js";
|
|
12
|
-
export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, type BlocklifyBlock, type BlocklifyBlockProps, type BlocklifyPreviewProps, type BlockMap, type DataBlokCPayload, } from "./preview.js";
|
|
12
|
+
export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, isPreviewEditable, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, type BlocklifyBlock, type BlocklifyBlockProps, type BlocklifyPreviewProps, type BlockMap, type BlockPath, type BlockPathSegment, type BlockReorderPayload, type DataBlokCPayload, type RenderNestedBlocks, } from "./preview.js";
|
|
13
13
|
export { default } from "./preview.js";
|
|
14
14
|
export { BlocklifyEntryView, blocklifyAssetUrl, createStarterBlockMap, starterBlockMap, type StarterBlockMapOptions, } from "./starter.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
* } from "@blocklify/preview";
|
|
10
10
|
*/
|
|
11
11
|
export { createBlocklifyClient, } from "./client.js";
|
|
12
|
-
export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, } from "./preview.js";
|
|
12
|
+
export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, isPreviewEditable, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, } from "./preview.js";
|
|
13
13
|
export { default } from "./preview.js";
|
|
14
14
|
export { BlocklifyEntryView, blocklifyAssetUrl, createStarterBlockMap, starterBlockMap, } from "./starter.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Class toggled on a block root to replay the focus blink. */
|
|
2
|
+
export declare const PREVIEW_FOCUS_BLINK_CLASS = "blocklify-preview-focus-blink";
|
|
3
|
+
export declare const PREVIEW_FOCUS_BLINK_MS = 400;
|
|
4
|
+
/**
|
|
5
|
+
* Editable outline, click blink, and drag-handle styles for the preview iframe.
|
|
6
|
+
* The admin sends this string to the iframe (`blocklify-preview-styles`). Themes do not import a CSS file.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PREVIEW_EDITABLE_CSS = "\n[data-blocklify-iframe-editable],\n[data-blok-uid] {\n cursor: pointer;\n outline: 1px dashed color-mix(in srgb, var(--primary-800, #7a9618) 56%, transparent);\n outline-offset: 0;\n transition: outline-color 0.15s, box-shadow 0.2s ease;\n}\n[data-blocklify-iframe-editable]:hover,\n[data-blok-uid]:hover {\n outline-color: var(--primary-800, #7a9618);\n}\n@keyframes blocklify-preview-focus-blink {\n 0% {\n box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-800, #7a9618) 50%, transparent);\n outline-color: color-mix(in srgb, var(--primary-800, #7a9618) 95%, transparent);\n }\n 50% {\n box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary, #e8fc9f) 45%, transparent);\n outline-color: var(--primary-800, #7a9618);\n }\n 100% {\n box-shadow: none;\n outline-color: color-mix(in srgb, var(--brand-mid, #2d5248) 55%, transparent);\n }\n}\n.blocklify-preview-focus-blink {\n animation: blocklify-preview-focus-blink 0.4s ease;\n}\n@media (prefers-reduced-motion: reduce) {\n .blocklify-preview-focus-blink {\n animation: none;\n }\n}\n.blocklify-editable--nested-interactive {\n pointer-events: none;\n}\n.blocklify-editable--nested-interactive > * {\n pointer-events: auto;\n}\n.blocklify-preview-non-interactive a,\n.blocklify-preview-non-interactive button:not(.blocklify-drag-handle) {\n pointer-events: none;\n cursor: default;\n}\n.blocklify-sortable-row {\n position: relative;\n min-width: 0;\n width: 100%;\n}\n.blocklify-sortable-row--dragging {\n opacity: 0.5;\n z-index: 20;\n}\n.blocklify-sortable-row--dragging > .blocklify-sortable-row__content {\n pointer-events: none;\n}\n.blocklify-sortable-row--horizontal {\n touch-action: pan-y;\n}\n.blocklify-sortable-drop-zone {\n min-height: 0;\n}\n.blocklify-sortable-drop-zone--grid {\n display: contents;\n}\n.blocklify-sortable-row__content {\n position: relative;\n z-index: 0;\n min-width: 0;\n width: 100%;\n height: 100%;\n}\n.blocklify-sortable-row__content .blocklify-drag-handle {\n z-index: 30;\n}\n.blocklify-sortable-row > .blocklify-drag-handle {\n z-index: 40;\n}\n.blocklify-sortable-row > .blocklify-drag-handle--container {\n z-index: 45;\n}\n.blocklify-drag-handle {\n position: absolute;\n top: 0.5rem;\n left: 0.5rem;\n z-index: 10;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n touch-action: none;\n height: 1.75rem;\n width: 1.75rem;\n padding: 0;\n cursor: grab;\n border-radius: 0.5rem;\n border: 1px solid color-mix(in srgb, var(--brand-mid, #2d5248) 14%, transparent);\n background: color-mix(in srgb, #ffffff 92%, var(--primary, #e8fc9f));\n color: color-mix(in srgb, var(--brand-mid, #2d5248) 72%, transparent);\n line-height: 0;\n box-shadow:\n 0 1px 2px color-mix(in srgb, var(--brand-darker, #0f2320) 10%, transparent),\n 0 0 0 1px color-mix(in srgb, #ffffff 70%, transparent) inset;\n opacity: 0;\n pointer-events: none;\n transition:\n opacity 0.15s ease,\n transform 0.15s ease,\n color 0.15s ease,\n background-color 0.15s ease,\n border-color 0.15s ease,\n box-shadow 0.15s ease;\n}\n.blocklify-drag-handle__icon {\n display: block;\n flex-shrink: 0;\n}\n.blocklify-sortable-row:hover > .blocklify-drag-handle,\n.blocklify-sortable-row:focus-within > .blocklify-drag-handle,\n.blocklify-sortable-row--dragging > .blocklify-drag-handle,\n.blocklify-drag-handle:focus-visible {\n opacity: 1;\n pointer-events: auto;\n}\n.blocklify-sortable-row:hover > .blocklify-drag-handle {\n transform: scale(1.02);\n}\n.blocklify-drag-handle:hover {\n color: var(--brand-mid, #2d5248);\n background: var(--primary, #e8fc9f);\n border-color: color-mix(in srgb, var(--primary-800, #7a9618) 35%, transparent);\n box-shadow:\n 0 2px 8px color-mix(in srgb, var(--brand-darker, #0f2320) 14%, transparent),\n 0 0 0 1px color-mix(in srgb, #ffffff 80%, transparent) inset;\n}\n.blocklify-drag-handle:focus-visible {\n outline: 2px solid var(--primary-800, #7a9618);\n outline-offset: 2px;\n}\n.blocklify-drag-handle:active,\n.blocklify-sortable-row--dragging > .blocklify-drag-handle {\n cursor: grabbing;\n color: var(--brand-dark, #1c3530);\n background: var(--primary-600, #ccee50);\n transform: scale(0.98);\n}\nhtml.dark .blocklify-drag-handle {\n border-color: color-mix(in srgb, var(--primary, #e8fc9f) 18%, transparent);\n background: color-mix(in srgb, var(--surface, #161a19) 88%, var(--brand-mid, #2d5248));\n color: color-mix(in srgb, var(--primary, #e8fc9f) 78%, transparent);\n box-shadow:\n 0 1px 3px color-mix(in srgb, #000000 35%, transparent),\n 0 0 0 1px color-mix(in srgb, #ffffff 6%, transparent) inset;\n}\nhtml.dark .blocklify-drag-handle:hover {\n color: var(--brand-darker, #0f2320);\n background: var(--primary, #e8fc9f);\n border-color: color-mix(in srgb, var(--primary, #e8fc9f) 55%, transparent);\n}\nhtml.dark .blocklify-drag-handle:active,\nhtml.dark .blocklify-sortable-row--dragging > .blocklify-drag-handle {\n color: var(--brand-darker, #0f2320);\n background: var(--primary-600, #ccee50);\n}\n@media (hover: none) {\n .blocklify-drag-handle {\n opacity: 0.85;\n pointer-events: auto;\n }\n}\n.blocklify-drag-overlay {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n max-width: 16rem;\n padding: 0.5rem 0.75rem;\n border-radius: 0.5rem;\n border: 1px solid var(--border, #d4d4d8);\n background: var(--surface, #ffffff);\n color: var(--text, #18181b);\n box-shadow:\n 0 10px 28px color-mix(in srgb, var(--brand-darker, #0f2320) 18%, transparent),\n 0 0 0 1px color-mix(in srgb, var(--primary, #e8fc9f) 35%, transparent);\n cursor: grabbing;\n pointer-events: none;\n}\n.blocklify-drag-overlay__grip {\n flex-shrink: 0;\n color: var(--text-muted, #52525b);\n}\n.blocklify-drag-overlay__label {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 0.875rem;\n font-weight: 500;\n line-height: 1.25;\n}\nhtml.dark .blocklify-drag-overlay {\n border-color: var(--border, #2c3330);\n background: var(--surface, #161a19);\n color: var(--text, #f4f5f4);\n}\n";
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** Class toggled on a block root to replay the focus blink. */
|
|
2
|
+
export const PREVIEW_FOCUS_BLINK_CLASS = "blocklify-preview-focus-blink";
|
|
3
|
+
export const PREVIEW_FOCUS_BLINK_MS = 400;
|
|
4
|
+
/**
|
|
5
|
+
* Editable outline, click blink, and drag-handle styles for the preview iframe.
|
|
6
|
+
* The admin sends this string to the iframe (`blocklify-preview-styles`). Themes do not import a CSS file.
|
|
7
|
+
*/
|
|
8
|
+
export const PREVIEW_EDITABLE_CSS = `
|
|
9
|
+
[data-blocklify-iframe-editable],
|
|
10
|
+
[data-blok-uid] {
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
outline: 1px dashed color-mix(in srgb, var(--primary-800, #7a9618) 56%, transparent);
|
|
13
|
+
outline-offset: 0;
|
|
14
|
+
transition: outline-color 0.15s, box-shadow 0.2s ease;
|
|
15
|
+
}
|
|
16
|
+
[data-blocklify-iframe-editable]:hover,
|
|
17
|
+
[data-blok-uid]:hover {
|
|
18
|
+
outline-color: var(--primary-800, #7a9618);
|
|
19
|
+
}
|
|
20
|
+
@keyframes blocklify-preview-focus-blink {
|
|
21
|
+
0% {
|
|
22
|
+
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary-800, #7a9618) 50%, transparent);
|
|
23
|
+
outline-color: color-mix(in srgb, var(--primary-800, #7a9618) 95%, transparent);
|
|
24
|
+
}
|
|
25
|
+
50% {
|
|
26
|
+
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary, #e8fc9f) 45%, transparent);
|
|
27
|
+
outline-color: var(--primary-800, #7a9618);
|
|
28
|
+
}
|
|
29
|
+
100% {
|
|
30
|
+
box-shadow: none;
|
|
31
|
+
outline-color: color-mix(in srgb, var(--brand-mid, #2d5248) 55%, transparent);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
.${PREVIEW_FOCUS_BLINK_CLASS} {
|
|
35
|
+
animation: blocklify-preview-focus-blink 0.4s ease;
|
|
36
|
+
}
|
|
37
|
+
@media (prefers-reduced-motion: reduce) {
|
|
38
|
+
.${PREVIEW_FOCUS_BLINK_CLASS} {
|
|
39
|
+
animation: none;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
.blocklify-editable--nested-interactive {
|
|
43
|
+
pointer-events: none;
|
|
44
|
+
}
|
|
45
|
+
.blocklify-editable--nested-interactive > * {
|
|
46
|
+
pointer-events: auto;
|
|
47
|
+
}
|
|
48
|
+
.blocklify-preview-non-interactive a,
|
|
49
|
+
.blocklify-preview-non-interactive button:not(.blocklify-drag-handle) {
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
cursor: default;
|
|
52
|
+
}
|
|
53
|
+
.blocklify-sortable-row {
|
|
54
|
+
position: relative;
|
|
55
|
+
min-width: 0;
|
|
56
|
+
width: 100%;
|
|
57
|
+
}
|
|
58
|
+
.blocklify-sortable-row--dragging {
|
|
59
|
+
opacity: 0.5;
|
|
60
|
+
z-index: 20;
|
|
61
|
+
}
|
|
62
|
+
.blocklify-sortable-row--dragging > .blocklify-sortable-row__content {
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
.blocklify-sortable-row--horizontal {
|
|
66
|
+
touch-action: pan-y;
|
|
67
|
+
}
|
|
68
|
+
.blocklify-sortable-drop-zone {
|
|
69
|
+
min-height: 0;
|
|
70
|
+
}
|
|
71
|
+
.blocklify-sortable-drop-zone--grid {
|
|
72
|
+
display: contents;
|
|
73
|
+
}
|
|
74
|
+
.blocklify-sortable-row__content {
|
|
75
|
+
position: relative;
|
|
76
|
+
z-index: 0;
|
|
77
|
+
min-width: 0;
|
|
78
|
+
width: 100%;
|
|
79
|
+
height: 100%;
|
|
80
|
+
}
|
|
81
|
+
.blocklify-sortable-row__content .blocklify-drag-handle {
|
|
82
|
+
z-index: 30;
|
|
83
|
+
}
|
|
84
|
+
.blocklify-sortable-row > .blocklify-drag-handle {
|
|
85
|
+
z-index: 40;
|
|
86
|
+
}
|
|
87
|
+
.blocklify-sortable-row > .blocklify-drag-handle--container {
|
|
88
|
+
z-index: 45;
|
|
89
|
+
}
|
|
90
|
+
.blocklify-drag-handle {
|
|
91
|
+
position: absolute;
|
|
92
|
+
top: 0.5rem;
|
|
93
|
+
left: 0.5rem;
|
|
94
|
+
z-index: 10;
|
|
95
|
+
display: inline-flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
touch-action: none;
|
|
99
|
+
height: 1.75rem;
|
|
100
|
+
width: 1.75rem;
|
|
101
|
+
padding: 0;
|
|
102
|
+
cursor: grab;
|
|
103
|
+
border-radius: 0.5rem;
|
|
104
|
+
border: 1px solid color-mix(in srgb, var(--brand-mid, #2d5248) 14%, transparent);
|
|
105
|
+
background: color-mix(in srgb, #ffffff 92%, var(--primary, #e8fc9f));
|
|
106
|
+
color: color-mix(in srgb, var(--brand-mid, #2d5248) 72%, transparent);
|
|
107
|
+
line-height: 0;
|
|
108
|
+
box-shadow:
|
|
109
|
+
0 1px 2px color-mix(in srgb, var(--brand-darker, #0f2320) 10%, transparent),
|
|
110
|
+
0 0 0 1px color-mix(in srgb, #ffffff 70%, transparent) inset;
|
|
111
|
+
opacity: 0;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
transition:
|
|
114
|
+
opacity 0.15s ease,
|
|
115
|
+
transform 0.15s ease,
|
|
116
|
+
color 0.15s ease,
|
|
117
|
+
background-color 0.15s ease,
|
|
118
|
+
border-color 0.15s ease,
|
|
119
|
+
box-shadow 0.15s ease;
|
|
120
|
+
}
|
|
121
|
+
.blocklify-drag-handle__icon {
|
|
122
|
+
display: block;
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
.blocklify-sortable-row:hover > .blocklify-drag-handle,
|
|
126
|
+
.blocklify-sortable-row:focus-within > .blocklify-drag-handle,
|
|
127
|
+
.blocklify-sortable-row--dragging > .blocklify-drag-handle,
|
|
128
|
+
.blocklify-drag-handle:focus-visible {
|
|
129
|
+
opacity: 1;
|
|
130
|
+
pointer-events: auto;
|
|
131
|
+
}
|
|
132
|
+
.blocklify-sortable-row:hover > .blocklify-drag-handle {
|
|
133
|
+
transform: scale(1.02);
|
|
134
|
+
}
|
|
135
|
+
.blocklify-drag-handle:hover {
|
|
136
|
+
color: var(--brand-mid, #2d5248);
|
|
137
|
+
background: var(--primary, #e8fc9f);
|
|
138
|
+
border-color: color-mix(in srgb, var(--primary-800, #7a9618) 35%, transparent);
|
|
139
|
+
box-shadow:
|
|
140
|
+
0 2px 8px color-mix(in srgb, var(--brand-darker, #0f2320) 14%, transparent),
|
|
141
|
+
0 0 0 1px color-mix(in srgb, #ffffff 80%, transparent) inset;
|
|
142
|
+
}
|
|
143
|
+
.blocklify-drag-handle:focus-visible {
|
|
144
|
+
outline: 2px solid var(--primary-800, #7a9618);
|
|
145
|
+
outline-offset: 2px;
|
|
146
|
+
}
|
|
147
|
+
.blocklify-drag-handle:active,
|
|
148
|
+
.blocklify-sortable-row--dragging > .blocklify-drag-handle {
|
|
149
|
+
cursor: grabbing;
|
|
150
|
+
color: var(--brand-dark, #1c3530);
|
|
151
|
+
background: var(--primary-600, #ccee50);
|
|
152
|
+
transform: scale(0.98);
|
|
153
|
+
}
|
|
154
|
+
html.dark .blocklify-drag-handle {
|
|
155
|
+
border-color: color-mix(in srgb, var(--primary, #e8fc9f) 18%, transparent);
|
|
156
|
+
background: color-mix(in srgb, var(--surface, #161a19) 88%, var(--brand-mid, #2d5248));
|
|
157
|
+
color: color-mix(in srgb, var(--primary, #e8fc9f) 78%, transparent);
|
|
158
|
+
box-shadow:
|
|
159
|
+
0 1px 3px color-mix(in srgb, #000000 35%, transparent),
|
|
160
|
+
0 0 0 1px color-mix(in srgb, #ffffff 6%, transparent) inset;
|
|
161
|
+
}
|
|
162
|
+
html.dark .blocklify-drag-handle:hover {
|
|
163
|
+
color: var(--brand-darker, #0f2320);
|
|
164
|
+
background: var(--primary, #e8fc9f);
|
|
165
|
+
border-color: color-mix(in srgb, var(--primary, #e8fc9f) 55%, transparent);
|
|
166
|
+
}
|
|
167
|
+
html.dark .blocklify-drag-handle:active,
|
|
168
|
+
html.dark .blocklify-sortable-row--dragging > .blocklify-drag-handle {
|
|
169
|
+
color: var(--brand-darker, #0f2320);
|
|
170
|
+
background: var(--primary-600, #ccee50);
|
|
171
|
+
}
|
|
172
|
+
@media (hover: none) {
|
|
173
|
+
.blocklify-drag-handle {
|
|
174
|
+
opacity: 0.85;
|
|
175
|
+
pointer-events: auto;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
.blocklify-drag-overlay {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: 0.5rem;
|
|
182
|
+
max-width: 16rem;
|
|
183
|
+
padding: 0.5rem 0.75rem;
|
|
184
|
+
border-radius: 0.5rem;
|
|
185
|
+
border: 1px solid var(--border, #d4d4d8);
|
|
186
|
+
background: var(--surface, #ffffff);
|
|
187
|
+
color: var(--text, #18181b);
|
|
188
|
+
box-shadow:
|
|
189
|
+
0 10px 28px color-mix(in srgb, var(--brand-darker, #0f2320) 18%, transparent),
|
|
190
|
+
0 0 0 1px color-mix(in srgb, var(--primary, #e8fc9f) 35%, transparent);
|
|
191
|
+
cursor: grabbing;
|
|
192
|
+
pointer-events: none;
|
|
193
|
+
}
|
|
194
|
+
.blocklify-drag-overlay__grip {
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
color: var(--text-muted, #52525b);
|
|
197
|
+
}
|
|
198
|
+
.blocklify-drag-overlay__label {
|
|
199
|
+
min-width: 0;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
text-overflow: ellipsis;
|
|
202
|
+
white-space: nowrap;
|
|
203
|
+
font-size: 0.875rem;
|
|
204
|
+
font-weight: 500;
|
|
205
|
+
line-height: 1.25;
|
|
206
|
+
}
|
|
207
|
+
html.dark .blocklify-drag-overlay {
|
|
208
|
+
border-color: var(--border, #2c3330);
|
|
209
|
+
background: var(--surface, #161a19);
|
|
210
|
+
color: var(--text, #f4f5f4);
|
|
211
|
+
}
|
|
212
|
+
`;
|
package/dist/preview.d.ts
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { BlocklifyBlock, BlockMap, BlockReorderPayload } from "./types.js";
|
|
2
|
+
export type { BlocklifyBlock, BlocklifyBlockProps, BlockMap, BlockReorderPayload, RenderNestedBlocks, } from "./types.js";
|
|
3
|
+
export type { BlockPath, BlockPathSegment } from "./block-path.js";
|
|
2
4
|
/**
|
|
3
|
-
* Blocklify live-preview connector.
|
|
4
|
-
*
|
|
5
|
-
* to
|
|
5
|
+
* Blocklify live-preview connector.
|
|
6
|
+
* Editable outlines and drag handles are defined in PREVIEW_EDITABLE_CSS and sent from the admin
|
|
7
|
+
* to the iframe via postMessage (`blocklify-preview-styles`).
|
|
6
8
|
*/
|
|
7
|
-
/** Block shape sent by Blocklify (draft content or API). Your block components receive this. */
|
|
8
|
-
export type BlocklifyBlock = {
|
|
9
|
-
type: string;
|
|
10
|
-
_uid?: string;
|
|
11
|
-
[k: string]: unknown;
|
|
12
|
-
};
|
|
13
|
-
/** Props passed to block components: block, optional BlocklifyEditable (spread on root in iframe), optional blockMap (for nested blocks). */
|
|
14
|
-
export type BlocklifyBlockProps = {
|
|
15
|
-
block: BlocklifyBlock;
|
|
16
|
-
BlocklifyEditable?: Record<string, unknown>;
|
|
17
|
-
blockMap?: BlockMap;
|
|
18
|
-
};
|
|
19
|
-
/** Map block type string → component that receives { block, BlocklifyEditable? }. Spread BlocklifyEditable on the root element for click-to-focus in the preview iframe. */
|
|
20
|
-
export type BlockMap = Record<string, React.ComponentType<BlocklifyBlockProps>>;
|
|
21
9
|
/** Storyblok-style payload for data-blok-c (JSON string). */
|
|
22
10
|
export type DataBlokCPayload = {
|
|
23
11
|
name: string;
|
|
@@ -33,11 +21,11 @@ export declare function getDataBlokC(block: BlocklifyBlock, options?: {
|
|
|
33
21
|
spaceId?: string;
|
|
34
22
|
blockId?: string;
|
|
35
23
|
}): string;
|
|
24
|
+
/** True when `BlocklifyEditable` props are the preview click-to-focus props, not an empty object. */
|
|
25
|
+
export declare function isPreviewEditable(editable: Record<string, unknown> | undefined): boolean;
|
|
36
26
|
/**
|
|
37
|
-
* Returns props to spread on the root element of a block so it becomes clickable in the preview iframe
|
|
38
|
-
* Sets data-blok-c
|
|
39
|
-
* Only returns non-empty when in iframe with onBlockClick; otherwise returns {} so components work unchanged outside preview.
|
|
40
|
-
* Use in the preview renderer; components just spread the BlocklifyEditable prop they receive.
|
|
27
|
+
* Returns props to spread on the root element of a block so it becomes clickable in the preview iframe.
|
|
28
|
+
* Sets data-blok-c and data-blok-uid. Only returns props when `onBlockClick` is set.
|
|
41
29
|
* Clicks send the block's _uid so the editor can open the form for the correct block.
|
|
42
30
|
*/
|
|
43
31
|
export declare function BlocklifyEditable(block: BlocklifyBlock, index: number, onBlockClick: ((uid: string) => void) | undefined, options?: {
|
|
@@ -50,16 +38,17 @@ export declare const BLOCKLIFY_PREVIEW_STYLES = "blocklify-preview-styles";
|
|
|
50
38
|
export declare const BLOCKLIFY_PEER_FOCUS = "blocklify-peer-focus";
|
|
51
39
|
/** Message type the admin sends to scroll a block into view in the preview iframe (e.g. when focusing a field in the editor). */
|
|
52
40
|
export declare const BLOCKLIFY_SCROLL_TO_BLOCK = "blocklify-scroll-to-block";
|
|
53
|
-
|
|
54
|
-
export declare const PREVIEW_EDITABLE_CSS = "\n:root {\n --radius: 0.5rem;\n}\n[data-blocklify-iframe-editable],\n[data-blok-uid] {\n cursor: pointer !important;\n border: 2px solid #2563eb !important;\n border-radius: var(--radius) !important;\n padding: 6px !important;\n margin-bottom: 10px !important;\n box-sizing: border-box !important;\n transition: border-color 0.2s ease, background-color 0.2s ease !important;\n position: relative !important;\n min-height: 24px !important;\n}\n[data-blocklify-iframe-editable]:hover,\n[data-blok-uid]:hover {\n border-color: #1d4ed8 !important;\n background-color: rgba(37, 99, 235, 0.06) !important;\n}\n@keyframes blocklify-preview-focus-blink {\n 0% {\n box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);\n border-color: #2563eb !important;\n }\n 35% {\n box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.45);\n border-color: #1d4ed8 !important;\n }\n 100% {\n box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);\n border-color: #2563eb !important;\n }\n}\n.blocklify-preview-focus-blink {\n animation: blocklify-preview-focus-blink 0.55s ease-out 1 !important;\n}\n@media (prefers-reduced-motion: reduce) {\n .blocklify-preview-focus-blink {\n animation: none !important;\n }\n}\n";
|
|
41
|
+
export { PREVIEW_EDITABLE_CSS } from "./preview-styles.js";
|
|
55
42
|
/**
|
|
56
43
|
* Hook that connects your preview page to the Blocklify admin iframe:
|
|
57
44
|
* - Listens for draft content via postMessage (type: "blocklify-draft").
|
|
58
45
|
* - Sends block clicks back to the editor (type: "blocklify-block-click").
|
|
59
|
-
*
|
|
46
|
+
* - `applyBlockReorder` updates the local draft immediately; the sortable renderer posts the reorder.
|
|
60
47
|
*/
|
|
61
48
|
export declare function useBlocklifyPreview(blocksFieldName?: string): {
|
|
62
49
|
blocks: BlocklifyBlock[];
|
|
50
|
+
/** Full draft content object from the editor, including fields outside `blocksFieldName`. */
|
|
51
|
+
content: Record<string, unknown> | null;
|
|
63
52
|
isInIframe: boolean;
|
|
64
53
|
onBlockClick: (uid: string) => void;
|
|
65
54
|
showEmptyMessage: boolean;
|
|
@@ -67,11 +56,15 @@ export declare function useBlocklifyPreview(blocksFieldName?: string): {
|
|
|
67
56
|
spaceId: string | null;
|
|
68
57
|
/** Use this when rendering editable wrappers: parent-sent CSS or PREVIEW_EDITABLE_CSS. */
|
|
69
58
|
editableCss: string;
|
|
70
|
-
/** Peer-focused blocks: uid → { name, color }.
|
|
59
|
+
/** Peer-focused blocks: uid → { name, color }. */
|
|
71
60
|
peerFocusedBlocks: Record<string, {
|
|
72
61
|
name: string;
|
|
73
62
|
color: string;
|
|
74
63
|
}>;
|
|
64
|
+
/** CSS that paints peer outlines. Empty when nobody else is focused. */
|
|
65
|
+
peerFocusCss: string;
|
|
66
|
+
/** Apply a drag reorder to the local draft. The sortable tree posts `blocklify-block-reorder` after this. */
|
|
67
|
+
applyBlockReorder: (payload: BlockReorderPayload) => string | null;
|
|
75
68
|
};
|
|
76
69
|
type BlocklifyPreviewRendererProps = {
|
|
77
70
|
blocks: BlocklifyBlock[];
|
|
@@ -83,12 +76,22 @@ type BlocklifyPreviewRendererProps = {
|
|
|
83
76
|
editableCss?: string;
|
|
84
77
|
/** Optional: pass to each component so nested blocks (e.g. Page, Grid) can render. */
|
|
85
78
|
blockMapForNested?: BlockMap;
|
|
79
|
+
/** Field these blocks live on. Default "body". Nested columns use their own field name. */
|
|
80
|
+
blocksFieldName?: string;
|
|
81
|
+
/** Drag handles and reorder. Defaults to on when `onBlockClick` is set. */
|
|
82
|
+
sortable?: boolean;
|
|
83
|
+
/** Fields whose arrays can be reordered. Default: body, blocks, columns, items. */
|
|
84
|
+
sortableBlocksFields?: readonly string[];
|
|
85
|
+
/** Updates local draft content and returns the moved uid. */
|
|
86
|
+
onBlockReorder?: (payload: BlockReorderPayload) => string | null;
|
|
87
|
+
/** Inject PREVIEW_EDITABLE_CSS. Set false when a parent already injected it. */
|
|
88
|
+
injectStyles?: boolean;
|
|
86
89
|
};
|
|
87
90
|
/**
|
|
88
|
-
* Renders blocks using your blockMap. Passes BlocklifyEditable
|
|
89
|
-
*
|
|
91
|
+
* Renders blocks using your blockMap. Passes BlocklifyEditable so components can spread it on their root.
|
|
92
|
+
* In the preview iframe, wraps sortable lists with drag handles and posts `blocklify-block-reorder`.
|
|
90
93
|
*/
|
|
91
|
-
export declare function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss, blockMapForNested, }: BlocklifyPreviewRendererProps): import("react/jsx-runtime").JSX.Element;
|
|
94
|
+
export declare function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss, blockMapForNested, blocksFieldName, sortable, sortableBlocksFields, onBlockReorder, injectStyles, }: BlocklifyPreviewRendererProps): import("react/jsx-runtime").JSX.Element;
|
|
92
95
|
export type BlocklifyPreviewProps = {
|
|
93
96
|
/** Map of block type → component that receives { block }. Define your blocks in your app and pass this. */
|
|
94
97
|
blockMap: BlockMap;
|
|
@@ -98,14 +101,16 @@ export type BlocklifyPreviewProps = {
|
|
|
98
101
|
emptyMessage?: string;
|
|
99
102
|
/** Optional className for the root wrapper. */
|
|
100
103
|
className?: string;
|
|
104
|
+
/** Fields whose arrays can be reordered. Default: body, blocks, columns, items. */
|
|
105
|
+
sortableBlocksFields?: readonly string[];
|
|
101
106
|
};
|
|
102
107
|
/**
|
|
103
108
|
* Preview connector for Blocklify. Use this on your frontend’s preview page (e.g. /preview).
|
|
104
109
|
* You only need to define your block components and a blockMap; this component handles:
|
|
105
110
|
* - Receiving draft content from the Blocklify admin via postMessage
|
|
106
111
|
* - Sending block clicks back to the editor
|
|
107
|
-
* -
|
|
112
|
+
* - Dashed outlines, a click blink, and drag-and-drop reorder when embedded in the admin iframe
|
|
108
113
|
*/
|
|
109
|
-
export declare function BlocklifyPreview({ blockMap, blocksFieldName, emptyMessage, className, }: BlocklifyPreviewProps): import("react/jsx-runtime").JSX.Element;
|
|
114
|
+
export declare function BlocklifyPreview({ blockMap, blocksFieldName, emptyMessage, className, sortableBlocksFields, }: BlocklifyPreviewProps): import("react/jsx-runtime").JSX.Element;
|
|
110
115
|
/** Default export so both "import BlocklifyPreview from '...'" and "import { BlocklifyPreview } from '...'" work. */
|
|
111
116
|
export default BlocklifyPreview;
|
package/dist/preview.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { moveBlockInContent, toBlockPathSegment } from "./block-path.js";
|
|
5
|
+
import { PREVIEW_EDITABLE_CSS, PREVIEW_FOCUS_BLINK_CLASS, PREVIEW_FOCUS_BLINK_MS } from "./preview-styles.js";
|
|
6
|
+
import { PreviewSortableTree } from "./sortable-preview.js";
|
|
4
7
|
/**
|
|
5
8
|
* Builds the value for the data-blok-c attribute (Storyblok-style JSON string).
|
|
6
9
|
* Use when you need to set data-blok-c yourself; otherwise BlocklifyEditable includes it.
|
|
@@ -17,11 +20,21 @@ export function getDataBlokC(block, options) {
|
|
|
17
20
|
};
|
|
18
21
|
return JSON.stringify(payload);
|
|
19
22
|
}
|
|
23
|
+
/** True when `BlocklifyEditable` props are the preview click-to-focus props, not an empty object. */
|
|
24
|
+
export function isPreviewEditable(editable) {
|
|
25
|
+
return editable != null && Object.keys(editable).length > 0;
|
|
26
|
+
}
|
|
27
|
+
function pulsePreviewBlock(el) {
|
|
28
|
+
el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
|
|
29
|
+
void el.offsetWidth;
|
|
30
|
+
el.classList.add(PREVIEW_FOCUS_BLINK_CLASS);
|
|
31
|
+
window.setTimeout(() => {
|
|
32
|
+
el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
|
|
33
|
+
}, PREVIEW_FOCUS_BLINK_MS);
|
|
34
|
+
}
|
|
20
35
|
/**
|
|
21
|
-
* Returns props to spread on the root element of a block so it becomes clickable in the preview iframe
|
|
22
|
-
* Sets data-blok-c
|
|
23
|
-
* Only returns non-empty when in iframe with onBlockClick; otherwise returns {} so components work unchanged outside preview.
|
|
24
|
-
* Use in the preview renderer; components just spread the BlocklifyEditable prop they receive.
|
|
36
|
+
* Returns props to spread on the root element of a block so it becomes clickable in the preview iframe.
|
|
37
|
+
* Sets data-blok-c and data-blok-uid. Only returns props when `onBlockClick` is set.
|
|
25
38
|
* Clicks send the block's _uid so the editor can open the form for the correct block.
|
|
26
39
|
*/
|
|
27
40
|
export function BlocklifyEditable(block, index, onBlockClick, options) {
|
|
@@ -36,14 +49,17 @@ export function BlocklifyEditable(block, index, onBlockClick, options) {
|
|
|
36
49
|
tabIndex: 0,
|
|
37
50
|
title: "Click to focus in editor",
|
|
38
51
|
"aria-label": `Edit block ${index + 1}, ${block.type}`,
|
|
39
|
-
onClick: (
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
onClick: (event) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
event.stopPropagation();
|
|
55
|
+
if (event.currentTarget instanceof HTMLElement) {
|
|
56
|
+
pulsePreviewBlock(event.currentTarget);
|
|
57
|
+
}
|
|
42
58
|
onBlockClick(uid);
|
|
43
59
|
},
|
|
44
|
-
onKeyDown: (
|
|
45
|
-
if (
|
|
46
|
-
|
|
60
|
+
onKeyDown: (event) => {
|
|
61
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
62
|
+
event.preventDefault();
|
|
47
63
|
onBlockClick(uid);
|
|
48
64
|
}
|
|
49
65
|
},
|
|
@@ -57,16 +73,6 @@ export const BLOCKLIFY_PREVIEW_STYLES = "blocklify-preview-styles";
|
|
|
57
73
|
export const BLOCKLIFY_PEER_FOCUS = "blocklify-peer-focus";
|
|
58
74
|
/** Message type the admin sends to scroll a block into view in the preview iframe (e.g. when focusing a field in the editor). */
|
|
59
75
|
export const BLOCKLIFY_SCROLL_TO_BLOCK = "blocklify-scroll-to-block";
|
|
60
|
-
const PREVIEW_FOCUS_BLINK_CLASS = "blocklify-preview-focus-blink";
|
|
61
|
-
const PREVIEW_FOCUS_BLINK_MS = 550;
|
|
62
|
-
function pulsePreviewBlock(el) {
|
|
63
|
-
el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
|
|
64
|
-
void el.offsetWidth;
|
|
65
|
-
el.classList.add(PREVIEW_FOCUS_BLINK_CLASS);
|
|
66
|
-
window.setTimeout(() => {
|
|
67
|
-
el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
|
|
68
|
-
}, PREVIEW_FOCUS_BLINK_MS);
|
|
69
|
-
}
|
|
70
76
|
function scrollPreviewBlockIntoView(uid) {
|
|
71
77
|
if (typeof document === "undefined" || !uid)
|
|
72
78
|
return;
|
|
@@ -75,56 +81,27 @@ function scrollPreviewBlockIntoView(uid) {
|
|
|
75
81
|
document.querySelector(`[data-blocklify-uid="${escaped}"]`);
|
|
76
82
|
if (!(el instanceof HTMLElement))
|
|
77
83
|
return;
|
|
84
|
+
let parent = el.parentElement;
|
|
85
|
+
while (parent) {
|
|
86
|
+
const style = window.getComputedStyle(parent);
|
|
87
|
+
const overflowY = style.overflowY;
|
|
88
|
+
const canScrollY = (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
|
|
89
|
+
parent.scrollHeight > parent.clientHeight + 1;
|
|
90
|
+
if (canScrollY) {
|
|
91
|
+
const parentRect = parent.getBoundingClientRect();
|
|
92
|
+
const elRect = el.getBoundingClientRect();
|
|
93
|
+
const delta = elRect.top - parentRect.top - parent.clientHeight / 2 + elRect.height / 2;
|
|
94
|
+
parent.scrollBy({ top: delta, behavior: "smooth" });
|
|
95
|
+
pulsePreviewBlock(el);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
parent = parent.parentElement;
|
|
99
|
+
}
|
|
78
100
|
el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
|
79
101
|
pulsePreviewBlock(el);
|
|
80
102
|
}
|
|
81
|
-
|
|
82
|
-
export const PREVIEW_EDITABLE_CSS = `
|
|
83
|
-
:root {
|
|
84
|
-
--radius: 0.5rem;
|
|
85
|
-
}
|
|
86
|
-
[data-blocklify-iframe-editable],
|
|
87
|
-
[data-blok-uid] {
|
|
88
|
-
cursor: pointer !important;
|
|
89
|
-
border: 2px solid #2563eb !important;
|
|
90
|
-
border-radius: var(--radius) !important;
|
|
91
|
-
padding: 6px !important;
|
|
92
|
-
margin-bottom: 10px !important;
|
|
93
|
-
box-sizing: border-box !important;
|
|
94
|
-
transition: border-color 0.2s ease, background-color 0.2s ease !important;
|
|
95
|
-
position: relative !important;
|
|
96
|
-
min-height: 24px !important;
|
|
97
|
-
}
|
|
98
|
-
[data-blocklify-iframe-editable]:hover,
|
|
99
|
-
[data-blok-uid]:hover {
|
|
100
|
-
border-color: #1d4ed8 !important;
|
|
101
|
-
background-color: rgba(37, 99, 235, 0.06) !important;
|
|
102
|
-
}
|
|
103
|
-
@keyframes blocklify-preview-focus-blink {
|
|
104
|
-
0% {
|
|
105
|
-
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
|
|
106
|
-
border-color: #2563eb !important;
|
|
107
|
-
}
|
|
108
|
-
35% {
|
|
109
|
-
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.45);
|
|
110
|
-
border-color: #1d4ed8 !important;
|
|
111
|
-
}
|
|
112
|
-
100% {
|
|
113
|
-
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
|
|
114
|
-
border-color: #2563eb !important;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
.${PREVIEW_FOCUS_BLINK_CLASS} {
|
|
118
|
-
animation: blocklify-preview-focus-blink 0.55s ease-out 1 !important;
|
|
119
|
-
}
|
|
120
|
-
@media (prefers-reduced-motion: reduce) {
|
|
121
|
-
.${PREVIEW_FOCUS_BLINK_CLASS} {
|
|
122
|
-
animation: none !important;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
`;
|
|
103
|
+
export { PREVIEW_EDITABLE_CSS } from "./preview-styles.js";
|
|
126
104
|
function subscribeIsInIframe() {
|
|
127
|
-
// iframe embedding does not change for the lifetime of the page.
|
|
128
105
|
return () => { };
|
|
129
106
|
}
|
|
130
107
|
function getIsInIframeSnapshot() {
|
|
@@ -133,31 +110,49 @@ function getIsInIframeSnapshot() {
|
|
|
133
110
|
function getIsInIframeServerSnapshot() {
|
|
134
111
|
return false;
|
|
135
112
|
}
|
|
113
|
+
function blocksFromContent(content, blocksFieldName) {
|
|
114
|
+
const rawBlocks = content?.[blocksFieldName];
|
|
115
|
+
return Array.isArray(rawBlocks)
|
|
116
|
+
? rawBlocks.filter((block) => block != null && typeof block === "object" && typeof block.type === "string")
|
|
117
|
+
: [];
|
|
118
|
+
}
|
|
119
|
+
function peerFocusCssFor(peers) {
|
|
120
|
+
return Object.entries(peers)
|
|
121
|
+
.map(([uid, { color }]) => {
|
|
122
|
+
const safeUid = uid.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
123
|
+
return `[data-blok-uid="${safeUid}"], [data-blocklify-uid="${safeUid}"] { outline-color: ${color} !important; box-shadow: 0 0 0 2px ${color} !important; }`;
|
|
124
|
+
})
|
|
125
|
+
.join("\n");
|
|
126
|
+
}
|
|
136
127
|
/**
|
|
137
128
|
* Hook that connects your preview page to the Blocklify admin iframe:
|
|
138
129
|
* - Listens for draft content via postMessage (type: "blocklify-draft").
|
|
139
130
|
* - Sends block clicks back to the editor (type: "blocklify-block-click").
|
|
140
|
-
*
|
|
131
|
+
* - `applyBlockReorder` updates the local draft immediately; the sortable renderer posts the reorder.
|
|
141
132
|
*/
|
|
142
133
|
export function useBlocklifyPreview(blocksFieldName = "body") {
|
|
143
134
|
const [draftContent, setDraftContent] = useState(null);
|
|
135
|
+
const draftRef = useRef(null);
|
|
144
136
|
/** CSS for editable wrappers: from parent (postMessage) or fallback to PREVIEW_EDITABLE_CSS. */
|
|
145
137
|
const [receivedEditableCss, setReceivedEditableCss] = useState(null);
|
|
146
138
|
const [peerFocusedBlocks, setPeerFocusedBlocks] = useState({});
|
|
147
139
|
const isInIframe = useSyncExternalStore(subscribeIsInIframe, getIsInIframeSnapshot, getIsInIframeServerSnapshot);
|
|
148
140
|
const [spaceId, setSpaceId] = useState(null);
|
|
141
|
+
const setDraft = useCallback((content) => {
|
|
142
|
+
draftRef.current = content;
|
|
143
|
+
setDraftContent(content);
|
|
144
|
+
}, []);
|
|
149
145
|
useEffect(() => {
|
|
150
146
|
if (!isInIframe)
|
|
151
147
|
return;
|
|
152
148
|
const onMessage = (event) => {
|
|
153
149
|
if (event.data?.type === BLOCKLIFY_DRAFT && event.data.content != null) {
|
|
154
|
-
|
|
150
|
+
setDraft(event.data.content);
|
|
155
151
|
setSpaceId(typeof event.data.spaceId === "string" && event.data.spaceId.trim() !== ""
|
|
156
152
|
? event.data.spaceId
|
|
157
153
|
: null);
|
|
158
154
|
}
|
|
159
|
-
if (event.data?.type === BLOCKLIFY_PREVIEW_STYLES &&
|
|
160
|
-
typeof event.data.css === "string") {
|
|
155
|
+
if (event.data?.type === BLOCKLIFY_PREVIEW_STYLES && typeof event.data.css === "string") {
|
|
161
156
|
setReceivedEditableCss(event.data.css);
|
|
162
157
|
}
|
|
163
158
|
if (event.data?.type === BLOCKLIFY_PEER_FOCUS && event.data.peers != null) {
|
|
@@ -166,7 +161,6 @@ export function useBlocklifyPreview(blocksFieldName = "body") {
|
|
|
166
161
|
if (event.data?.type === BLOCKLIFY_SCROLL_TO_BLOCK &&
|
|
167
162
|
typeof event.data.uid === "string" &&
|
|
168
163
|
event.data.uid.trim() !== "") {
|
|
169
|
-
// Wait a frame so draft content / DOM from a concurrent update can settle.
|
|
170
164
|
requestAnimationFrame(() => {
|
|
171
165
|
scrollPreviewBlockIntoView(event.data.uid);
|
|
172
166
|
});
|
|
@@ -174,21 +168,29 @@ export function useBlocklifyPreview(blocksFieldName = "body") {
|
|
|
174
168
|
};
|
|
175
169
|
window.addEventListener("message", onMessage);
|
|
176
170
|
return () => window.removeEventListener("message", onMessage);
|
|
177
|
-
}, [isInIframe]);
|
|
171
|
+
}, [isInIframe, setDraft]);
|
|
178
172
|
const onBlockClick = useCallback((uid) => {
|
|
179
173
|
if (typeof window === "undefined" || window === window.parent)
|
|
180
174
|
return;
|
|
181
175
|
window.parent.postMessage({ type: BLOCKLIFY_BLOCK_CLICK, uid, blocksFieldName }, "*");
|
|
182
176
|
}, [blocksFieldName]);
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
177
|
+
const applyBlockReorder = useCallback((payload) => {
|
|
178
|
+
const current = draftRef.current;
|
|
179
|
+
if (!current)
|
|
180
|
+
return null;
|
|
181
|
+
const moved = moveBlockInContent(current, payload.sourcePath, payload.destinationPath);
|
|
182
|
+
if (!moved || moved.movedUid !== payload.movedUid)
|
|
183
|
+
return null;
|
|
184
|
+
draftRef.current = moved.next;
|
|
185
|
+
setDraftContent(moved.next);
|
|
186
|
+
return moved.movedUid;
|
|
187
|
+
}, []);
|
|
188
|
+
const blocks = blocksFromContent(draftContent, blocksFieldName);
|
|
189
189
|
const showEmptyMessage = isInIframe && blocks.length === 0 && draftContent === null;
|
|
190
190
|
return {
|
|
191
191
|
blocks,
|
|
192
|
+
/** Full draft content object from the editor, including fields outside `blocksFieldName`. */
|
|
193
|
+
content: draftContent,
|
|
192
194
|
isInIframe,
|
|
193
195
|
onBlockClick,
|
|
194
196
|
showEmptyMessage,
|
|
@@ -196,10 +198,20 @@ export function useBlocklifyPreview(blocksFieldName = "body") {
|
|
|
196
198
|
spaceId,
|
|
197
199
|
/** Use this when rendering editable wrappers: parent-sent CSS or PREVIEW_EDITABLE_CSS. */
|
|
198
200
|
editableCss: receivedEditableCss ?? PREVIEW_EDITABLE_CSS,
|
|
199
|
-
/** Peer-focused blocks: uid → { name, color }.
|
|
201
|
+
/** Peer-focused blocks: uid → { name, color }. */
|
|
200
202
|
peerFocusedBlocks,
|
|
203
|
+
/** CSS that paints peer outlines. Empty when nobody else is focused. */
|
|
204
|
+
peerFocusCss: peerFocusCssFor(peerFocusedBlocks),
|
|
205
|
+
/** Apply a drag reorder to the local draft. The sortable tree posts `blocklify-block-reorder` after this. */
|
|
206
|
+
applyBlockReorder,
|
|
201
207
|
};
|
|
202
208
|
}
|
|
209
|
+
const EMPTY_BLOCK_MAP = {};
|
|
210
|
+
function resolveComponent(blockMap, type) {
|
|
211
|
+
return (blockMap[type] ??
|
|
212
|
+
(type.endsWith("_block") ? blockMap[type.slice(0, -6)] : undefined) ??
|
|
213
|
+
UnknownBlockFallback);
|
|
214
|
+
}
|
|
203
215
|
/** Fallback when a block type has no component in the blockMap – never pass undefined to React. */
|
|
204
216
|
function UnknownBlockFallback({ block, BlocklifyEditable = {}, }) {
|
|
205
217
|
return (_jsxs("section", { style: {
|
|
@@ -212,45 +224,42 @@ function UnknownBlockFallback({ block, BlocklifyEditable = {}, }) {
|
|
|
212
224
|
fontSize: "0.875rem",
|
|
213
225
|
}, ...BlocklifyEditable, children: [_jsxs("strong", { style: { color: "#475569" }, children: ["Block type \"", block.type, "\" not found."] }), _jsx("p", { style: { margin: "0.5rem 0 0", lineHeight: 1.5 }, children: "Add a component for this block type to your project's blockMap (e.g. in your preview page) to render it here." })] }));
|
|
214
226
|
}
|
|
227
|
+
function PlainBlockList({ blocks, parentPath, fieldName, renderBlock, }) {
|
|
228
|
+
const renderNestedBlocks = useCallback((nestedBlocks, nestedParentPath, nestedFieldName) => (_jsx(PlainBlockList, { blocks: nestedBlocks, parentPath: nestedParentPath, fieldName: nestedFieldName, renderBlock: renderBlock })), [renderBlock]);
|
|
229
|
+
return (_jsx(_Fragment, { children: blocks.map((block, index) => {
|
|
230
|
+
const blockPath = toBlockPathSegment(parentPath, fieldName, index);
|
|
231
|
+
const key = block._uid ?? `${block.type}-${index}`;
|
|
232
|
+
return (_jsx(React.Fragment, { children: renderBlock({ block, blockPath, blockIndex: index, renderNestedBlocks }) }, key));
|
|
233
|
+
}) }));
|
|
234
|
+
}
|
|
215
235
|
/**
|
|
216
|
-
* Renders blocks using your blockMap. Passes BlocklifyEditable
|
|
217
|
-
*
|
|
236
|
+
* Renders blocks using your blockMap. Passes BlocklifyEditable so components can spread it on their root.
|
|
237
|
+
* In the preview iframe, wraps sortable lists with drag handles and posts `blocklify-block-reorder`.
|
|
218
238
|
*/
|
|
219
|
-
export function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss = PREVIEW_EDITABLE_CSS, blockMapForNested, }) {
|
|
220
|
-
const safeBlockMap = blockMap ??
|
|
239
|
+
export function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss = PREVIEW_EDITABLE_CSS, blockMapForNested, blocksFieldName = "body", sortable, sortableBlocksFields, onBlockReorder, injectStyles = true, }) {
|
|
240
|
+
const safeBlockMap = blockMap ?? EMPTY_BLOCK_MAP;
|
|
221
241
|
const nestedMap = blockMapForNested ?? safeBlockMap;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return (_jsx(Component, { block: block, BlocklifyEditable: blocklifyEditable, ...(nestedMap && { blockMap: nestedMap }) }, key));
|
|
233
|
-
})] }));
|
|
242
|
+
const enableSort = sortable ?? Boolean(onBlockClick);
|
|
243
|
+
const renderBlock = useCallback(({ block, blockPath, blockIndex, renderNestedBlocks }) => {
|
|
244
|
+
const Component = resolveComponent(safeBlockMap, block.type);
|
|
245
|
+
const blocklifyEditable = BlocklifyEditable(block, blockIndex, onBlockClick, {
|
|
246
|
+
spaceId: spaceId ?? undefined,
|
|
247
|
+
blockId: block.id,
|
|
248
|
+
});
|
|
249
|
+
return (_jsx(Component, { block: block, BlocklifyEditable: blocklifyEditable, blockMap: nestedMap, blockIndex: blockIndex, blockPath: blockPath, renderNestedBlocks: renderNestedBlocks }));
|
|
250
|
+
}, [nestedMap, onBlockClick, safeBlockMap, spaceId]);
|
|
251
|
+
return (_jsxs(_Fragment, { children: [injectStyles ? (_jsx("style", { id: "blocklify-preview-editable-styles", dangerouslySetInnerHTML: { __html: editableCss } })) : null, enableSort ? (_jsx(PreviewSortableTree, { blocks: blocks, fieldName: blocksFieldName, sortableBlocksFields: sortableBlocksFields, renderBlock: renderBlock, onBlockReorder: onBlockReorder })) : (_jsx(PlainBlockList, { blocks: blocks, parentPath: [], fieldName: blocksFieldName, renderBlock: renderBlock }))] }));
|
|
234
252
|
}
|
|
235
253
|
/**
|
|
236
254
|
* Preview connector for Blocklify. Use this on your frontend’s preview page (e.g. /preview).
|
|
237
255
|
* You only need to define your block components and a blockMap; this component handles:
|
|
238
256
|
* - Receiving draft content from the Blocklify admin via postMessage
|
|
239
257
|
* - Sending block clicks back to the editor
|
|
240
|
-
* -
|
|
258
|
+
* - Dashed outlines, a click blink, and drag-and-drop reorder when embedded in the admin iframe
|
|
241
259
|
*/
|
|
242
|
-
export function BlocklifyPreview({ blockMap, blocksFieldName = "body", emptyMessage = "Waiting for content from editor…", className, }) {
|
|
243
|
-
const { blocks, isInIframe, onBlockClick, showEmptyMessage, spaceId, editableCss,
|
|
244
|
-
|
|
245
|
-
.map(([uid, { color }]) => `[data-blok-uid="${uid}"] { border-color: ${color} !important; box-shadow: 0 0 0 2px ${color} !important; }`)
|
|
246
|
-
.join("\n");
|
|
247
|
-
return (_jsxs("div", { className: className ?? "min-h-screen bg-surface text-text blocklify-preview-non-interactive", children: [_jsx("style", { children: `
|
|
248
|
-
.blocklify-preview-non-interactive a,
|
|
249
|
-
.blocklify-preview-non-interactive button {
|
|
250
|
-
pointer-events: none;
|
|
251
|
-
cursor: default;
|
|
252
|
-
}
|
|
253
|
-
` }), peerFocusCss && (_jsx("style", { id: "blocklify-peer-focus-styles", children: peerFocusCss })), showEmptyMessage && (_jsx("div", { className: "flex min-h-screen items-center justify-center", children: _jsx("p", { className: "text-sm text-text-muted", children: emptyMessage }) })), _jsx(BlocklifyPreviewRenderer, { blocks: blocks, blockMap: blockMap, onBlockClick: isInIframe ? onBlockClick : undefined, spaceId: spaceId, editableCss: editableCss, blockMapForNested: blockMap })] }));
|
|
260
|
+
export function BlocklifyPreview({ blockMap, blocksFieldName = "body", emptyMessage = "Waiting for content from editor…", className, sortableBlocksFields, }) {
|
|
261
|
+
const { blocks, isInIframe, onBlockClick, showEmptyMessage, spaceId, editableCss, peerFocusCss, applyBlockReorder, } = useBlocklifyPreview(blocksFieldName);
|
|
262
|
+
return (_jsxs("div", { className: className ?? "min-h-screen bg-surface text-text blocklify-preview-non-interactive", children: [peerFocusCss ? _jsx("style", { id: "blocklify-peer-focus-styles", children: peerFocusCss }) : null, showEmptyMessage && (_jsx("div", { className: "flex min-h-screen items-center justify-center", children: _jsx("p", { className: "text-sm text-text-muted", children: emptyMessage }) })), _jsx(BlocklifyPreviewRenderer, { blocks: blocks, blockMap: blockMap, onBlockClick: isInIframe ? onBlockClick : undefined, spaceId: spaceId, editableCss: editableCss, blockMapForNested: blockMap, blocksFieldName: blocksFieldName, sortable: isInIframe, sortableBlocksFields: sortableBlocksFields, onBlockReorder: applyBlockReorder })] }));
|
|
254
263
|
}
|
|
255
264
|
/** Default export so both "import BlocklifyPreview from '...'" and "import { BlocklifyPreview } from '...'" work. */
|
|
256
265
|
export default BlocklifyPreview;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type BlockPath } from "./block-path.js";
|
|
3
|
+
import type { BlocklifyBlock, BlockReorderPayload, RenderNestedBlocks } from "./types.js";
|
|
4
|
+
export type RenderSortableBlock = (args: {
|
|
5
|
+
block: BlocklifyBlock;
|
|
6
|
+
blockPath: BlockPath;
|
|
7
|
+
blockIndex: number;
|
|
8
|
+
renderNestedBlocks: RenderNestedBlocks;
|
|
9
|
+
}) => ReactNode;
|
|
10
|
+
export declare function PreviewSortableTree({ blocks, fieldName, sortableBlocksFields, renderBlock, onBlockReorder, }: {
|
|
11
|
+
blocks: BlocklifyBlock[];
|
|
12
|
+
fieldName: string;
|
|
13
|
+
sortableBlocksFields?: readonly string[];
|
|
14
|
+
renderBlock: RenderSortableBlock;
|
|
15
|
+
/** Updates local draft content. Return the moved uid when the move was applied. */
|
|
16
|
+
onBlockReorder?: (payload: BlockReorderPayload) => string | null;
|
|
17
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { closestCenter, DndContext, DragOverlay, PointerSensor, useDroppable, useSensor, useSensors, } from "@dnd-kit/core";
|
|
5
|
+
import { SortableContext, horizontalListSortingStrategy, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
|
|
6
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
7
|
+
import { canMoveBlockToDestination, createSortableFieldsSet, getBlocksFieldNameFromPath, getBlocksListKey, getPathKey, getSortableContextId, getSortableId, isGridSortableField, isNestedGridDropPath, isNestedSortableContainer, isPathInsideBlock, isDropIntoOwnNestedField, isSameBlocksList, isSortableBlocksField, isVirtualPreviewBlock, toBlockPathSegment, } from "./block-path.js";
|
|
8
|
+
const BLOCKLIFY_BLOCK_REORDER = "blocklify-block-reorder";
|
|
9
|
+
const restrictToHorizontalAxis = ({ transform }) => ({
|
|
10
|
+
...transform,
|
|
11
|
+
y: 0,
|
|
12
|
+
});
|
|
13
|
+
function getSortableStrategy(fieldName) {
|
|
14
|
+
return isGridSortableField(fieldName) ? horizontalListSortingStrategy : verticalListSortingStrategy;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Column children only collide with siblings. Body blocks ignore nested cells
|
|
18
|
+
* so they reorder around a columns block instead of dropping into it.
|
|
19
|
+
*/
|
|
20
|
+
const previewCollisionDetection = (args) => {
|
|
21
|
+
const collisions = closestCenter(args);
|
|
22
|
+
const activePath = args.active.data.current?.path;
|
|
23
|
+
if (activePath && isGridSortableField(getBlocksFieldNameFromPath(activePath))) {
|
|
24
|
+
const sourceListKey = getBlocksListKey(activePath);
|
|
25
|
+
return collisions.filter((collision) => {
|
|
26
|
+
const container = args.droppableContainers.find((entry) => entry.id === collision.id);
|
|
27
|
+
const path = container?.data.current?.path;
|
|
28
|
+
if (!path?.length)
|
|
29
|
+
return false;
|
|
30
|
+
return getBlocksListKey(path) === sourceListKey;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return collisions.filter((collision) => {
|
|
34
|
+
const container = args.droppableContainers.find((entry) => entry.id === collision.id);
|
|
35
|
+
const path = container?.data.current?.path;
|
|
36
|
+
if (!path?.length)
|
|
37
|
+
return true;
|
|
38
|
+
if (isNestedGridDropPath(path))
|
|
39
|
+
return false;
|
|
40
|
+
if (activePath && isPathInsideBlock(path, activePath))
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
function emitBlockReorder(payload) {
|
|
46
|
+
if (typeof window === "undefined" || window === window.parent)
|
|
47
|
+
return;
|
|
48
|
+
window.parent.postMessage({ type: BLOCKLIFY_BLOCK_REORDER, ...payload }, "*");
|
|
49
|
+
}
|
|
50
|
+
function GripIcon({ className }) {
|
|
51
|
+
return (_jsxs("svg", { className: className, width: "14", height: "14", viewBox: "0 0 14 14", fill: "currentColor", "aria-hidden": true, children: [_jsx("circle", { cx: "4.5", cy: "3", r: "1.15" }), _jsx("circle", { cx: "9.5", cy: "3", r: "1.15" }), _jsx("circle", { cx: "4.5", cy: "7", r: "1.15" }), _jsx("circle", { cx: "9.5", cy: "7", r: "1.15" }), _jsx("circle", { cx: "4.5", cy: "11", r: "1.15" }), _jsx("circle", { cx: "9.5", cy: "11", r: "1.15" })] }));
|
|
52
|
+
}
|
|
53
|
+
function blockLabel(block) {
|
|
54
|
+
const raw = block.type.replace(/_/g, " ");
|
|
55
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
56
|
+
}
|
|
57
|
+
function BlockDragOverlayCard({ block }) {
|
|
58
|
+
return (_jsxs("div", { className: "blocklify-drag-overlay", children: [_jsx(GripIcon, { className: "blocklify-drag-overlay__grip" }), _jsx("span", { className: "blocklify-drag-overlay__label", children: blockLabel(block) })] }));
|
|
59
|
+
}
|
|
60
|
+
function SortableListDropZone({ parentPath, fieldName, itemCount, gridLayout = false, children, }) {
|
|
61
|
+
const listId = getSortableContextId(parentPath, fieldName);
|
|
62
|
+
const { setNodeRef } = useDroppable({
|
|
63
|
+
id: `${listId}:drop-zone`,
|
|
64
|
+
data: {
|
|
65
|
+
path: toBlockPathSegment(parentPath, fieldName, itemCount),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return (_jsx("div", { ref: setNodeRef, className: gridLayout
|
|
69
|
+
? "blocklify-sortable-drop-zone blocklify-sortable-drop-zone--grid"
|
|
70
|
+
: "blocklify-sortable-drop-zone", children: children }));
|
|
71
|
+
}
|
|
72
|
+
function PreviewSortableBlock({ block, blockPath, blockIndex, lockHorizontal = false, renderBlock, renderNestedBlocks, }) {
|
|
73
|
+
const sortableId = getSortableId(block, blockPath);
|
|
74
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
|
75
|
+
id: sortableId,
|
|
76
|
+
data: { path: blockPath, block },
|
|
77
|
+
});
|
|
78
|
+
const lockedTransform = transform && lockHorizontal ? { ...transform, y: 0 } : transform;
|
|
79
|
+
const style = lockedTransform
|
|
80
|
+
? { transform: CSS.Transform.toString(lockedTransform), transition }
|
|
81
|
+
: undefined;
|
|
82
|
+
const isContainer = isNestedSortableContainer(block.type);
|
|
83
|
+
return (_jsxs("div", { ref: setNodeRef, style: style, className: [
|
|
84
|
+
"blocklify-sortable-row",
|
|
85
|
+
isDragging ? "blocklify-sortable-row--dragging" : null,
|
|
86
|
+
lockHorizontal ? "blocklify-sortable-row--horizontal" : null,
|
|
87
|
+
]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join(" "), children: [_jsx("button", { ref: setActivatorNodeRef, type: "button", className: isContainer
|
|
90
|
+
? "blocklify-drag-handle blocklify-drag-handle--container"
|
|
91
|
+
: "blocklify-drag-handle", ...listeners, ...attributes, "aria-label": isContainer ? "Drag to reorder columns" : "Drag to reorder", title: isContainer ? "Drag to reorder columns" : "Drag to reorder", children: _jsx(GripIcon, { className: "blocklify-drag-handle__icon" }) }), _jsx("div", { className: "blocklify-sortable-row__content", children: renderBlock({ block, blockPath, blockIndex, renderNestedBlocks }) })] }));
|
|
92
|
+
}
|
|
93
|
+
function renderBlockList(blocks, parentPath, fieldName, sortableFields, renderBlock, renderNestedBlocks) {
|
|
94
|
+
const pathKey = getPathKey(parentPath);
|
|
95
|
+
const sortable = isSortableBlocksField(fieldName, sortableFields);
|
|
96
|
+
if (!sortable) {
|
|
97
|
+
return (_jsx(_Fragment, { children: blocks.map((block, index) => {
|
|
98
|
+
const blockPath = toBlockPathSegment(parentPath, fieldName, index);
|
|
99
|
+
const key = block._uid ?? `${pathKey}:${fieldName}:${index}`;
|
|
100
|
+
return (_jsx("div", { children: renderBlock({ block, blockPath, blockIndex: index, renderNestedBlocks }) }, key));
|
|
101
|
+
}) }));
|
|
102
|
+
}
|
|
103
|
+
const sortableItems = blocks
|
|
104
|
+
.map((block, index) => ({ block, path: toBlockPathSegment(parentPath, fieldName, index) }))
|
|
105
|
+
.filter(({ block }) => !isVirtualPreviewBlock(block))
|
|
106
|
+
.map(({ block, path }) => getSortableId(block, path));
|
|
107
|
+
const gridLayout = isGridSortableField(fieldName);
|
|
108
|
+
const renderedBlocks = blocks.map((block, index) => {
|
|
109
|
+
const blockPath = toBlockPathSegment(parentPath, fieldName, index);
|
|
110
|
+
const key = block._uid ?? `${pathKey}:${fieldName}:${index}`;
|
|
111
|
+
if (isVirtualPreviewBlock(block)) {
|
|
112
|
+
return (_jsx("div", { children: renderBlock({ block, blockPath, blockIndex: index, renderNestedBlocks }) }, key));
|
|
113
|
+
}
|
|
114
|
+
return (_jsx(PreviewSortableBlock, { block: block, blockPath: blockPath, blockIndex: index, lockHorizontal: gridLayout, renderBlock: renderBlock, renderNestedBlocks: renderNestedBlocks }, key));
|
|
115
|
+
});
|
|
116
|
+
return (_jsx(SortableListDropZone, { parentPath: parentPath, fieldName: fieldName, itemCount: sortableItems.length, gridLayout: gridLayout, children: _jsx(SortableContext, { id: getSortableContextId(parentPath, fieldName), items: sortableItems, strategy: getSortableStrategy(fieldName), children: renderedBlocks }) }));
|
|
117
|
+
}
|
|
118
|
+
function resolveDropFromEvent(event) {
|
|
119
|
+
const activePath = event.active.data.current?.path;
|
|
120
|
+
let overPath = event.over?.data.current?.path;
|
|
121
|
+
const activeBlock = event.active.data.current?.block;
|
|
122
|
+
if (!event.over || !activePath || !overPath)
|
|
123
|
+
return null;
|
|
124
|
+
if (activePath.length === 0 || overPath.length === 0)
|
|
125
|
+
return null;
|
|
126
|
+
const sourceInGrid = isGridSortableField(getBlocksFieldNameFromPath(activePath));
|
|
127
|
+
if (sourceInGrid) {
|
|
128
|
+
if (!isSameBlocksList(activePath, overPath))
|
|
129
|
+
return null;
|
|
130
|
+
if (isDropIntoOwnNestedField(activePath, overPath))
|
|
131
|
+
return null;
|
|
132
|
+
return { sourcePath: activePath, destinationPath: overPath };
|
|
133
|
+
}
|
|
134
|
+
if (isNestedGridDropPath(overPath)) {
|
|
135
|
+
const lifted = overPath.slice(0, -1);
|
|
136
|
+
if (lifted.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
overPath = lifted;
|
|
139
|
+
}
|
|
140
|
+
if (!canMoveBlockToDestination(activeBlock?.type, activePath, overPath))
|
|
141
|
+
return null;
|
|
142
|
+
if (isDropIntoOwnNestedField(activePath, overPath))
|
|
143
|
+
return null;
|
|
144
|
+
return { sourcePath: activePath, destinationPath: overPath };
|
|
145
|
+
}
|
|
146
|
+
export function PreviewSortableTree({ blocks, fieldName, sortableBlocksFields, renderBlock, onBlockReorder, }) {
|
|
147
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
|
148
|
+
const sortableFields = useMemo(() => createSortableFieldsSet(sortableBlocksFields), [sortableBlocksFields]);
|
|
149
|
+
const lastCrossContainerMove = useRef(null);
|
|
150
|
+
const [activeBlock, setActiveBlock] = useState(null);
|
|
151
|
+
const [lockHorizontal, setLockHorizontal] = useState(false);
|
|
152
|
+
const renderNestedBlocks = useCallback(function renderNestedBlocks(nestedBlocks, parentPath, nestedFieldName) {
|
|
153
|
+
return renderBlockList(nestedBlocks, parentPath, nestedFieldName, sortableFields, renderBlock, renderNestedBlocks);
|
|
154
|
+
}, [renderBlock, sortableFields]);
|
|
155
|
+
const handleDragStart = useCallback((event) => {
|
|
156
|
+
const block = event.active.data.current?.block;
|
|
157
|
+
const path = event.active.data.current?.path;
|
|
158
|
+
setActiveBlock(block ?? null);
|
|
159
|
+
setLockHorizontal(!!path && isGridSortableField(getBlocksFieldNameFromPath(path)));
|
|
160
|
+
}, []);
|
|
161
|
+
const clearActive = useCallback(() => {
|
|
162
|
+
setActiveBlock(null);
|
|
163
|
+
setLockHorizontal(false);
|
|
164
|
+
}, []);
|
|
165
|
+
const handleDragOver = useCallback((event) => {
|
|
166
|
+
const resolved = resolveDropFromEvent(event);
|
|
167
|
+
if (!resolved || !onBlockReorder)
|
|
168
|
+
return;
|
|
169
|
+
const { sourcePath, destinationPath } = resolved;
|
|
170
|
+
if (isSameBlocksList(sourcePath, destinationPath)) {
|
|
171
|
+
lastCrossContainerMove.current = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const moveKey = `${getBlocksListKey(sourcePath)}->${getBlocksListKey(destinationPath)}:${destinationPath
|
|
175
|
+
.map((segment) => `${segment.field}:${segment.index}`)
|
|
176
|
+
.join("/")}`;
|
|
177
|
+
if (lastCrossContainerMove.current === moveKey)
|
|
178
|
+
return;
|
|
179
|
+
const movedUid = onBlockReorder({
|
|
180
|
+
blocksFieldName: getBlocksFieldNameFromPath(sourcePath),
|
|
181
|
+
movedUid: event.active.data.current?.block?._uid ?? "",
|
|
182
|
+
sourcePath,
|
|
183
|
+
destinationPath,
|
|
184
|
+
});
|
|
185
|
+
if (movedUid)
|
|
186
|
+
lastCrossContainerMove.current = moveKey;
|
|
187
|
+
}, [onBlockReorder]);
|
|
188
|
+
const handleDragEnd = useCallback((event) => {
|
|
189
|
+
clearActive();
|
|
190
|
+
lastCrossContainerMove.current = null;
|
|
191
|
+
const resolved = resolveDropFromEvent(event);
|
|
192
|
+
if (!resolved || !onBlockReorder)
|
|
193
|
+
return;
|
|
194
|
+
const { sourcePath, destinationPath } = resolved;
|
|
195
|
+
const sourceLeaf = sourcePath[sourcePath.length - 1];
|
|
196
|
+
const destLeaf = destinationPath[destinationPath.length - 1];
|
|
197
|
+
if (isSameBlocksList(sourcePath, destinationPath) && sourceLeaf.index === destLeaf.index) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const activeBlockData = event.active.data.current?.block;
|
|
201
|
+
const payload = {
|
|
202
|
+
blocksFieldName: getBlocksFieldNameFromPath(sourcePath),
|
|
203
|
+
movedUid: activeBlockData?._uid ?? "",
|
|
204
|
+
sourcePath,
|
|
205
|
+
destinationPath,
|
|
206
|
+
};
|
|
207
|
+
const movedUid = onBlockReorder(payload);
|
|
208
|
+
if (!movedUid)
|
|
209
|
+
return;
|
|
210
|
+
emitBlockReorder({ ...payload, movedUid });
|
|
211
|
+
}, [clearActive, onBlockReorder]);
|
|
212
|
+
return (_jsxs(DndContext, { sensors: sensors, collisionDetection: previewCollisionDetection, modifiers: lockHorizontal ? [restrictToHorizontalAxis] : undefined, onDragStart: handleDragStart, onDragOver: handleDragOver, onDragEnd: handleDragEnd, onDragCancel: clearActive, children: [renderBlockList(blocks, [], fieldName, sortableFields, renderBlock, renderNestedBlocks), _jsx(DragOverlay, { dropAnimation: null, children: activeBlock ? _jsx(BlockDragOverlayCard, { block: activeBlock }) : null })] }));
|
|
213
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import type { BlockPath } from "./block-path.js";
|
|
3
|
+
/** Block shape sent by Blocklify (draft content or API). Your block components receive this. */
|
|
4
|
+
export type BlocklifyBlock = {
|
|
5
|
+
type: string;
|
|
6
|
+
_uid?: string;
|
|
7
|
+
[k: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type RenderNestedBlocks = (nestedBlocks: BlocklifyBlock[], parentPath: BlockPath, fieldName: string) => ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* Props passed to block components.
|
|
12
|
+
* Spread `BlocklifyEditable` on the root element for click-to-focus in the preview iframe.
|
|
13
|
+
* `renderNestedBlocks` is set while preview drag-and-drop is active so columns and pages can reorder children.
|
|
14
|
+
*/
|
|
15
|
+
export type BlocklifyBlockProps = {
|
|
16
|
+
block: BlocklifyBlock;
|
|
17
|
+
BlocklifyEditable?: Record<string, unknown>;
|
|
18
|
+
blockMap?: BlockMap;
|
|
19
|
+
blockIndex?: number;
|
|
20
|
+
blockPath?: BlockPath;
|
|
21
|
+
renderNestedBlocks?: RenderNestedBlocks;
|
|
22
|
+
};
|
|
23
|
+
/** Map block type string → component that receives `{ block, BlocklifyEditable? }`. */
|
|
24
|
+
export type BlockMap = Record<string, ComponentType<BlocklifyBlockProps>>;
|
|
25
|
+
/** Payload posted to the editor as `blocklify-block-reorder`. */
|
|
26
|
+
export type BlockReorderPayload = {
|
|
27
|
+
blocksFieldName: string;
|
|
28
|
+
movedUid: string;
|
|
29
|
+
sourcePath: BlockPath;
|
|
30
|
+
destinationPath: BlockPath;
|
|
31
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blocklify/preview",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Blocklify frontend SDK: Content API client and live-preview React helpers for theme blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,8 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/BodhiMooij/blocklify.git"
|
|
39
|
-
"directory": "packages/preview"
|
|
38
|
+
"url": "git+https://github.com/BodhiMooij/blocklify-preview.git"
|
|
40
39
|
},
|
|
41
40
|
"scripts": {
|
|
42
41
|
"build": "tsc -p tsconfig.json",
|
|
@@ -54,6 +53,11 @@
|
|
|
54
53
|
"optional": false
|
|
55
54
|
}
|
|
56
55
|
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@dnd-kit/core": "^6.3.1",
|
|
58
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
59
|
+
"@dnd-kit/utilities": "^3.2.2"
|
|
60
|
+
},
|
|
57
61
|
"devDependencies": {
|
|
58
62
|
"@types/react": "19.2.14",
|
|
59
63
|
"@types/react-dom": "19.2.3",
|