@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 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 React from "react";
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. Editable wrapper styles (border, hover) are defined here (PREVIEW_EDITABLE_CSS)
4
- * and sent from the admin to the iframe via postMessage (blocklify-preview-styles). The other app only needs
5
- * to use this lib and listen for that message; it will receive and inject the same styles so no duplication.
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 (like Storyblok’s storyblokEditable).
38
- * Sets data-blok-c (Storyblok-style JSON: name, uid, space?, id?) and data-blok-uid for compatibility.
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
- /** Single source of truth for editable block styling in the preview iframe. Sent by the admin via postMessage so the other app doesn’t need to duplicate it. */
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
- * Use this when your preview page is loaded inside the Blocklify admin iframe.
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 }. Inject as colored borders to show who is editing what. */
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 (from BlocklifyEditable()) so components can spread it on their root for click-to-focus in the iframe.
89
- * Styles are injected from editableCss (sent by admin via postMessage). No wrapper div – components apply {...BlocklifyEditable} themselves.
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
- * - Empty state message and editable borders when embedded in the admin iframe
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 (like Storyblok’s storyblokEditable).
22
- * Sets data-blok-c (Storyblok-style JSON: name, uid, space?, id?) and data-blok-uid for compatibility.
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: (e) => {
40
- e.preventDefault();
41
- e.stopPropagation();
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: (e) => {
45
- if (e.key === "Enter" || e.key === " ") {
46
- e.preventDefault();
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
- /** Single source of truth for editable block styling in the preview iframe. Sent by the admin via postMessage so the other app doesn’t need to duplicate it. */
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
- * Use this when your preview page is loaded inside the Blocklify admin iframe.
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
- setDraftContent(event.data.content);
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 rawBlocks = draftContent?.[blocksFieldName];
184
- const blocks = Array.isArray(rawBlocks)
185
- ? rawBlocks.filter((b) => b != null &&
186
- typeof b === "object" &&
187
- typeof b.type === "string")
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 }. Inject as colored borders to show who is editing what. */
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 (from BlocklifyEditable()) so components can spread it on their root for click-to-focus in the iframe.
217
- * Styles are injected from editableCss (sent by admin via postMessage). No wrapper div – components apply {...BlocklifyEditable} themselves.
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 resolveComponent = (type) => safeBlockMap[type] ??
223
- (type.endsWith("_block") ? safeBlockMap[type.slice(0, -6)] : undefined) ??
224
- UnknownBlockFallback;
225
- return (_jsxs(_Fragment, { children: [_jsx("style", { id: "blocklify-preview-editable-styles", dangerouslySetInnerHTML: { __html: editableCss } }), blocks.map((block, i) => {
226
- const Component = resolveComponent(block.type);
227
- const key = block._uid ?? i;
228
- const blocklifyEditable = BlocklifyEditable(block, i, onBlockClick, {
229
- spaceId: spaceId ?? undefined,
230
- blockId: block.id,
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
- * - Empty state message and editable borders when embedded in the admin iframe
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, peerFocusedBlocks } = useBlocklifyPreview(blocksFieldName);
244
- const peerFocusCss = Object.entries(peerFocusedBlocks)
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
+ }
@@ -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.0",
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",