@jackuait/blok 0.10.8 → 0.10.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
- package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -6
- package/src/components/block/index.ts +36 -0
- package/src/components/blocks.ts +191 -5
- package/src/components/modules/api/blocks.ts +6 -4
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
- package/src/components/modules/blockManager/blockManager.ts +364 -23
- package/src/components/modules/blockManager/hierarchy.ts +164 -8
- package/src/components/modules/blockManager/operations.ts +223 -26
- package/src/components/modules/blockManager/types.ts +13 -1
- package/src/components/modules/blockManager/yjs-sync.ts +48 -3
- package/src/components/modules/drag/DragController.ts +209 -8
- package/src/components/modules/drag/operations/DragOperations.ts +153 -20
- package/src/components/modules/paste/handlers/base.ts +48 -20
- package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
- package/src/components/modules/paste/index.ts +20 -0
- package/src/components/modules/saver.ts +75 -5
- package/src/components/modules/toolbar/index.ts +41 -60
- package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
- package/src/components/modules/yjs/block-observer.ts +87 -23
- package/src/components/modules/yjs/document-store.ts +37 -11
- package/src/components/modules/yjs/index.ts +83 -7
- package/src/components/modules/yjs/types.ts +35 -2
- package/src/components/modules/yjs/undo-history.ts +116 -5
- package/src/components/utils/data-model-transform.ts +81 -7
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- package/src/styles/main.css +5 -0
- package/src/tools/callout/constants.ts +0 -1
- package/src/tools/callout/dom-builder.ts +1 -11
- package/src/tools/callout/index.ts +0 -6
- package/src/tools/header/index.ts +14 -1
- package/src/tools/toggle/constants.ts +2 -1
- package/src/tools/toggle/dom-builder.ts +7 -0
- package/src/tools/toggle/index.ts +14 -1
- package/src/tools/toggle/toggle-lifecycle.ts +24 -0
|
@@ -844,31 +844,105 @@ const processRootCalloutItem = (
|
|
|
844
844
|
* @param blocks - array of flat blocks with parent/content references
|
|
845
845
|
* @returns collapsed array with nested structures
|
|
846
846
|
*/
|
|
847
|
+
/**
|
|
848
|
+
* Groups one block under its parent in the derived-content map if it has a
|
|
849
|
+
* valid parent reference. Helper extracted for collapseToLegacy reconciliation.
|
|
850
|
+
*/
|
|
851
|
+
const appendChildToDerivedContent = (
|
|
852
|
+
block: OutputBlockData,
|
|
853
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
854
|
+
derivedContent: Map<BlockId, BlockId[]>
|
|
855
|
+
): void => {
|
|
856
|
+
if (!block.id || !block.parent || !blockById.has(block.parent)) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const siblings = derivedContent.get(block.parent);
|
|
860
|
+
|
|
861
|
+
if (siblings === undefined) {
|
|
862
|
+
derivedContent.set(block.parent, [block.id]);
|
|
863
|
+
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
siblings.push(block.id);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Merges live (parent-derived) ids into the existing content[] preserving its
|
|
871
|
+
* order, dropping any dead ids that don't resolve to a block in the input.
|
|
872
|
+
*/
|
|
873
|
+
const mergeContentIds = (
|
|
874
|
+
existingContent: BlockId[] | undefined,
|
|
875
|
+
derivedIds: BlockId[],
|
|
876
|
+
blockById: Map<BlockId, OutputBlockData>
|
|
877
|
+
): BlockId[] => {
|
|
878
|
+
const existing = Array.isArray(existingContent) ? existingContent : [];
|
|
879
|
+
const merged = existing.filter((id) => blockById.has(id));
|
|
880
|
+
|
|
881
|
+
for (const id of derivedIds) {
|
|
882
|
+
if (!merged.includes(id)) {
|
|
883
|
+
merged.push(id);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return merged;
|
|
888
|
+
};
|
|
889
|
+
|
|
847
890
|
export const collapseToLegacy = (blocks: OutputBlockData[]): OutputBlockData[] => {
|
|
891
|
+
// Defense-in-depth: reconcile each parent's content[] from children's parent
|
|
892
|
+
// fields before processing. Saver is the primary source of truth for content[]
|
|
893
|
+
// (see src/components/modules/saver.ts#doSave), but this pass guarantees the
|
|
894
|
+
// invariant `child.parent === X ⇒ X.content.includes(child.id)` even when
|
|
895
|
+
// OutputBlockData originates from a path that bypassed the saver — migrations,
|
|
896
|
+
// external JSON, tests, 3rd-party consumers. Without this, stale content[]
|
|
897
|
+
// causes processRootCalloutItem to eject real children as root siblings.
|
|
898
|
+
const reconciledBlocks = blocks.map((block) => ({ ...block }));
|
|
899
|
+
const reconciledById = new Map<BlockId, OutputBlockData>();
|
|
900
|
+
|
|
901
|
+
for (const block of reconciledBlocks) {
|
|
902
|
+
if (block.id) {
|
|
903
|
+
reconciledById.set(block.id, block);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const derivedContent = new Map<BlockId, BlockId[]>();
|
|
908
|
+
|
|
909
|
+
for (const block of reconciledBlocks) {
|
|
910
|
+
appendChildToDerivedContent(block, reconciledById, derivedContent);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
for (const [parentId, derivedIds] of derivedContent) {
|
|
914
|
+
const parent = reconciledById.get(parentId);
|
|
915
|
+
|
|
916
|
+
if (parent === undefined) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
parent.content = mergeContentIds(parent.content, derivedIds, reconciledById);
|
|
920
|
+
}
|
|
921
|
+
|
|
848
922
|
// Build a map of blocks by ID for quick lookup
|
|
849
923
|
const blockMap = new Map<BlockId, OutputBlockData>();
|
|
850
924
|
|
|
851
|
-
for (const block of
|
|
925
|
+
for (const block of reconciledBlocks) {
|
|
852
926
|
if (block.id) {
|
|
853
927
|
blockMap.set(block.id, block);
|
|
854
928
|
}
|
|
855
929
|
}
|
|
856
930
|
|
|
857
931
|
// If no flat-model list, toggle, or callout blocks, just strip hierarchy fields and return
|
|
858
|
-
const hasFlatListBlocks =
|
|
859
|
-
const hasFlatToggleBlocks =
|
|
860
|
-
const hasFlatToggleableHeaders =
|
|
861
|
-
const hasFlatCalloutBlocks =
|
|
932
|
+
const hasFlatListBlocks = reconciledBlocks.some(isFlatModelListBlock);
|
|
933
|
+
const hasFlatToggleBlocks = reconciledBlocks.some(isFlatModelToggleBlock);
|
|
934
|
+
const hasFlatToggleableHeaders = reconciledBlocks.some(b => isToggleableHeaderBlock(b) && !b.parent);
|
|
935
|
+
const hasFlatCalloutBlocks = reconciledBlocks.some(isFlatModelCalloutBlock);
|
|
862
936
|
|
|
863
937
|
if (!hasFlatListBlocks && !hasFlatToggleBlocks && !hasFlatToggleableHeaders && !hasFlatCalloutBlocks) {
|
|
864
|
-
return
|
|
938
|
+
return reconciledBlocks.map(stripHierarchyFields);
|
|
865
939
|
}
|
|
866
940
|
|
|
867
941
|
// Process blocks, converting root flat-model list blocks to legacy List blocks
|
|
868
942
|
const result: OutputBlockData[] = [];
|
|
869
943
|
const processedIds = new Set<BlockId>();
|
|
870
944
|
|
|
871
|
-
for (const block of
|
|
945
|
+
for (const block of reconciledBlocks) {
|
|
872
946
|
const alreadyProcessed = block.id && processedIds.has(block.id);
|
|
873
947
|
|
|
874
948
|
if (alreadyProcessed) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { OutputBlockData } from '@/types';
|
|
2
|
+
import type { BlockId } from '../../../types/data-formats/block-id';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hierarchy invariant validator.
|
|
6
|
+
*
|
|
7
|
+
* Every block with `parent: X` must appear in `X.content`, and every id in a
|
|
8
|
+
* block's `content[]` must resolve to a block whose `parent` points back. Any
|
|
9
|
+
* drift between the two representations is the signature of the callout paste
|
|
10
|
+
* ejection bug (and its siblings across toggle, toggleable header, list, and
|
|
11
|
+
* any future container block).
|
|
12
|
+
*
|
|
13
|
+
* This util exists so tests and saver-level assertions can detect drift at
|
|
14
|
+
* any point in the pipeline — load, save, collapse, or post-mutation — without
|
|
15
|
+
* hand-rolling the same loop. Treat it as the single source of truth for the
|
|
16
|
+
* parent/content invariant.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HierarchyViolation {
|
|
20
|
+
kind:
|
|
21
|
+
| 'child-parent-missing'
|
|
22
|
+
| 'child-not-in-parent-content'
|
|
23
|
+
| 'content-id-dangling'
|
|
24
|
+
| 'content-parent-mismatch'
|
|
25
|
+
| 'content-duplicate';
|
|
26
|
+
blockId: BlockId | undefined;
|
|
27
|
+
parentId?: BlockId;
|
|
28
|
+
childId?: BlockId;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pushViolation = (violations: HierarchyViolation[], v: HierarchyViolation): void => {
|
|
33
|
+
violations.push(v);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const checkParentLinks = (
|
|
37
|
+
block: OutputBlockData,
|
|
38
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
39
|
+
violations: HierarchyViolation[]
|
|
40
|
+
): void => {
|
|
41
|
+
if (block.parent === undefined || block.parent === null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const parent = blockById.get(block.parent);
|
|
45
|
+
|
|
46
|
+
if (parent === undefined) {
|
|
47
|
+
pushViolation(violations, {
|
|
48
|
+
kind: 'child-parent-missing',
|
|
49
|
+
blockId: block.id,
|
|
50
|
+
parentId: block.parent,
|
|
51
|
+
message: `Block ${String(block.id)} references missing parent ${String(block.parent)}`,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (block.id === undefined || !Array.isArray(parent.content) || !parent.content.includes(block.id)) {
|
|
57
|
+
pushViolation(violations, {
|
|
58
|
+
kind: 'child-not-in-parent-content',
|
|
59
|
+
blockId: block.id,
|
|
60
|
+
parentId: block.parent,
|
|
61
|
+
message: `Block ${String(block.id)} has parent=${String(block.parent)} but that parent's content[] does not include it`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const checkContentArray = (
|
|
67
|
+
block: OutputBlockData,
|
|
68
|
+
blockById: Map<BlockId, OutputBlockData>,
|
|
69
|
+
violations: HierarchyViolation[]
|
|
70
|
+
): void => {
|
|
71
|
+
if (!Array.isArray(block.content)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const seen = new Set<BlockId>();
|
|
75
|
+
|
|
76
|
+
for (const childId of block.content) {
|
|
77
|
+
if (seen.has(childId)) {
|
|
78
|
+
pushViolation(violations, {
|
|
79
|
+
kind: 'content-duplicate',
|
|
80
|
+
blockId: block.id,
|
|
81
|
+
childId,
|
|
82
|
+
message: `Block ${String(block.id)}.content[] contains duplicate id ${String(childId)}`,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
seen.add(childId);
|
|
87
|
+
|
|
88
|
+
const child = blockById.get(childId);
|
|
89
|
+
|
|
90
|
+
if (child === undefined) {
|
|
91
|
+
pushViolation(violations, {
|
|
92
|
+
kind: 'content-id-dangling',
|
|
93
|
+
blockId: block.id,
|
|
94
|
+
childId,
|
|
95
|
+
message: `Block ${String(block.id)}.content[] references missing child ${String(childId)}`,
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (child.parent !== block.id) {
|
|
100
|
+
pushViolation(violations, {
|
|
101
|
+
kind: 'content-parent-mismatch',
|
|
102
|
+
blockId: block.id,
|
|
103
|
+
childId,
|
|
104
|
+
message: `Block ${String(block.id)}.content[] includes ${String(childId)} but that child's parent is ${String(child.parent)}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const validateHierarchy = (blocks: OutputBlockData[]): HierarchyViolation[] => {
|
|
111
|
+
const violations: HierarchyViolation[] = [];
|
|
112
|
+
const blockById = new Map<BlockId, OutputBlockData>();
|
|
113
|
+
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
if (block.id !== undefined) {
|
|
116
|
+
blockById.set(block.id, block);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
checkParentLinks(block, blockById, violations);
|
|
122
|
+
checkContentArray(block, blockById, violations);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return violations;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const assertHierarchy = (blocks: OutputBlockData[], context: string): void => {
|
|
129
|
+
const violations = validateHierarchy(blocks);
|
|
130
|
+
|
|
131
|
+
if (violations.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const summary = violations.map(v => ` - ${v.message}`).join('\n');
|
|
135
|
+
|
|
136
|
+
throw new Error(`Hierarchy invariant violated at ${context}:\n${summary}`);
|
|
137
|
+
};
|
package/src/styles/main.css
CHANGED
|
@@ -1306,6 +1306,11 @@
|
|
|
1306
1306
|
@apply p-0 m-0 min-h-[1.6em];
|
|
1307
1307
|
}
|
|
1308
1308
|
|
|
1309
|
+
/* List items inside table cells use tight 2px top/bottom spacing */
|
|
1310
|
+
[data-blok-table-cell-blocks] [data-blok-tool="list"] {
|
|
1311
|
+
@apply py-0 mt-[2px] mb-[2px];
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1309
1314
|
/* ─── Cell content placement ──────────────────────────────────── */
|
|
1310
1315
|
|
|
1311
1316
|
[data-blok-cell-placement="top-center"] {
|
|
@@ -29,4 +29,3 @@ export const WRAPPER_STYLES = 'rounded-xl pl-8 pr-4 py-[5px] my-1 flex items-sta
|
|
|
29
29
|
// h-[38px] = py-[7px]×2 + 1.5rem×1 = 14+24; explicit height prevents platform-specific emoji font metrics from inflating the button
|
|
30
30
|
export const EMOJI_BUTTON_STYLES = 'text-[1.5rem] leading-[1] cursor-pointer bg-transparent border-0 px-0 py-[7px] h-[38px] flex-shrink-0 select-none';
|
|
31
31
|
export const CHILDREN_STYLES = 'flex-1 min-w-0';
|
|
32
|
-
export const DRAG_ZONE_STYLES = 'absolute left-0 top-0 h-full cursor-grab select-none';
|
|
@@ -6,14 +6,12 @@ import {
|
|
|
6
6
|
WRAPPER_STYLES,
|
|
7
7
|
EMOJI_BUTTON_STYLES,
|
|
8
8
|
CHILDREN_STYLES,
|
|
9
|
-
DRAG_ZONE_STYLES,
|
|
10
9
|
} from './constants';
|
|
11
10
|
|
|
12
11
|
export interface CalloutDOMRefs {
|
|
13
12
|
wrapper: HTMLElement;
|
|
14
13
|
emojiButton: HTMLButtonElement;
|
|
15
14
|
childContainer: HTMLElement;
|
|
16
|
-
dragZone: HTMLElement;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
export interface BuildCalloutDOMOptions {
|
|
@@ -53,13 +51,5 @@ export function buildCalloutDOM(options: BuildCalloutDOMOptions): CalloutDOMRefs
|
|
|
53
51
|
wrapper.appendChild(emojiButton);
|
|
54
52
|
wrapper.appendChild(childContainer);
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
// sits behind emoji button so emoji clicks pass through
|
|
58
|
-
const dragZone = document.createElement('span');
|
|
59
|
-
dragZone.className = DRAG_ZONE_STYLES;
|
|
60
|
-
dragZone.style.width = '32px'; // matches pl-8 left padding
|
|
61
|
-
dragZone.setAttribute('data-callout-drag-zone', '');
|
|
62
|
-
wrapper.prepend(dragZone);
|
|
63
|
-
|
|
64
|
-
return { wrapper, emojiButton, childContainer, dragZone };
|
|
54
|
+
return { wrapper, emojiButton, childContainer };
|
|
65
55
|
}
|
|
@@ -66,7 +66,6 @@ export class CalloutTool implements BlockTool {
|
|
|
66
66
|
private _dom: CalloutDOMRefs | null = null;
|
|
67
67
|
private _emojiPicker: EmojiPicker | null = null;
|
|
68
68
|
private _colorPicker: ColorPickerHandle | null = null;
|
|
69
|
-
private _dragZone: HTMLElement | null = null;
|
|
70
69
|
private blockId?: string;
|
|
71
70
|
|
|
72
71
|
constructor({ data, api, readOnly, block }: BlockToolConstructorOptions<CalloutData, CalloutConfig>) {
|
|
@@ -121,7 +120,6 @@ export class CalloutTool implements BlockTool {
|
|
|
121
120
|
});
|
|
122
121
|
|
|
123
122
|
this._dom = dom;
|
|
124
|
-
this._dragZone = dom.dragZone;
|
|
125
123
|
this.applyColors();
|
|
126
124
|
|
|
127
125
|
if (!this.readOnly) {
|
|
@@ -262,10 +260,6 @@ export class CalloutTool implements BlockTool {
|
|
|
262
260
|
}
|
|
263
261
|
}
|
|
264
262
|
|
|
265
|
-
public get dragZone(): HTMLElement | null {
|
|
266
|
-
return this._dragZone;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
263
|
private syncPickerActiveColors(): void {
|
|
270
264
|
if (this._colorPicker === null) {
|
|
271
265
|
return;
|
|
@@ -23,7 +23,7 @@ import { PLACEHOLDER_CLASSES, setupPlaceholder } from '../../components/utils/pl
|
|
|
23
23
|
import { twMerge } from '../../components/utils/tw';
|
|
24
24
|
import { BODY_PLACEHOLDER_STYLES, TOGGLE_ATTR } from '../toggle/constants';
|
|
25
25
|
import { buildArrow } from '../toggle/dom-builder';
|
|
26
|
-
import { updateArrowState, updateBodyPlaceholderVisibility, updateChildrenVisibility } from '../toggle/toggle-lifecycle';
|
|
26
|
+
import { updateArrowState, updateBodyPlaceholderVisibility, updateChildrenVisibility, updateToggleEmptyState } from '../toggle/toggle-lifecycle';
|
|
27
27
|
import { handleHeaderToggleEnter, handleHeaderToggleBackspace } from './header-toggle-keyboard';
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -764,6 +764,7 @@ export class Header implements BlockTool {
|
|
|
764
764
|
*/
|
|
765
765
|
private buildWrapper(): HTMLElement {
|
|
766
766
|
const wrapper = document.createElement('div');
|
|
767
|
+
wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
|
|
767
768
|
|
|
768
769
|
// Inner row: positioning context for the arrow (only heading height, not children).
|
|
769
770
|
const headerRow = document.createElement('div');
|
|
@@ -799,12 +800,21 @@ export class Header implements BlockTool {
|
|
|
799
800
|
// Block DOM mutations inside the children container from triggering the header tool's
|
|
800
801
|
// didMutated → syncBlockDataToYjs path (same rationale as the toggle list tool).
|
|
801
802
|
childContainer.setAttribute('data-blok-mutation-free', 'true');
|
|
803
|
+
/**
|
|
804
|
+
* Listen for typing inside child blocks so the empty-state attribute
|
|
805
|
+
* (and the grayish arrow it drives) tracks what the user types in real time.
|
|
806
|
+
*/
|
|
807
|
+
childContainer.addEventListener('input', this.handleChildContainerInput);
|
|
802
808
|
this._childContainerElement = childContainer;
|
|
803
809
|
wrapper.appendChild(childContainer);
|
|
804
810
|
|
|
805
811
|
return wrapper;
|
|
806
812
|
}
|
|
807
813
|
|
|
814
|
+
private handleChildContainerInput = (): void => {
|
|
815
|
+
updateToggleEmptyState(this._wrapper, this._childContainerElement);
|
|
816
|
+
};
|
|
817
|
+
|
|
808
818
|
/**
|
|
809
819
|
* Wrap the heading element in a new wrapper div containing the toggle arrow,
|
|
810
820
|
* replacing the heading's current position in the DOM.
|
|
@@ -814,6 +824,7 @@ export class Header implements BlockTool {
|
|
|
814
824
|
const parent = this._element.parentNode;
|
|
815
825
|
|
|
816
826
|
this._wrapper = document.createElement('div');
|
|
827
|
+
this._wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
|
|
817
828
|
|
|
818
829
|
// Inner row: positioning context for the arrow (only heading height, not children).
|
|
819
830
|
const headerRow = document.createElement('div');
|
|
@@ -916,6 +927,8 @@ export class Header implements BlockTool {
|
|
|
916
927
|
this._isOpen,
|
|
917
928
|
this.readOnly
|
|
918
929
|
);
|
|
930
|
+
|
|
931
|
+
updateToggleEmptyState(this._wrapper, this._childContainerElement);
|
|
919
932
|
}
|
|
920
933
|
|
|
921
934
|
/**
|
|
@@ -43,7 +43,7 @@ export const TOGGLE_WRAPPER_STYLES = 'flex items-center';
|
|
|
43
43
|
/**
|
|
44
44
|
* Styles for the toggle arrow button
|
|
45
45
|
*/
|
|
46
|
-
export const ARROW_STYLES = 'flex-shrink-0 p-[8px] flex items-center justify-center cursor-pointer select-none rounded can-hover:hover:bg-item-hover-bg transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none';
|
|
46
|
+
export const ARROW_STYLES = 'flex-shrink-0 p-[8px] flex items-center justify-center cursor-pointer select-none rounded can-hover:hover:bg-item-hover-bg transition-colors duration-200 ease-in-out focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none in-data-[blok-toggle-empty=true]:text-gray-text';
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* SVG icon for the toggle arrow
|
|
@@ -77,4 +77,5 @@ export const TOGGLE_ATTR = {
|
|
|
77
77
|
toggleContent: 'data-blok-toggle-content',
|
|
78
78
|
toggleBodyPlaceholder: 'data-blok-toggle-body-placeholder',
|
|
79
79
|
toggleChildren: 'data-blok-toggle-children',
|
|
80
|
+
toggleEmpty: 'data-blok-toggle-empty',
|
|
80
81
|
} as const;
|
|
@@ -73,6 +73,13 @@ export const buildToggleItem = (context: ToggleDOMBuilderContext): ToggleBuildRe
|
|
|
73
73
|
wrapper.className = BASE_STYLES;
|
|
74
74
|
wrapper.setAttribute(DATA_ATTR.tool, TOOL_NAME);
|
|
75
75
|
wrapper.setAttribute(TOGGLE_ATTR.toggleOpen, String(isOpen));
|
|
76
|
+
/**
|
|
77
|
+
* Empty state default: assume no children until the tool syncs real state
|
|
78
|
+
* via updateToggleEmptyState() in its lifecycle methods. This drives the
|
|
79
|
+
* grayish arrow styling applied through the in-data-[blok-toggle-empty=true]
|
|
80
|
+
* Tailwind variant on ARROW_STYLES.
|
|
81
|
+
*/
|
|
82
|
+
wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, 'true');
|
|
76
83
|
|
|
77
84
|
const headerRow = document.createElement('div');
|
|
78
85
|
headerRow.className = TOGGLE_WRAPPER_STYLES;
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { clean } from '../../components/utils/sanitizer';
|
|
28
28
|
import { ARIA_LABEL_COLLAPSE_KEY, ARIA_LABEL_EXPAND_KEY, BODY_PLACEHOLDER_KEY, PLACEHOLDER_KEY, TOOL_NAME } from './constants';
|
|
29
29
|
import { IconToggleList } from '../../components/icons';
|
|
30
|
-
import { renderToggleItem, updateArrowState, updateChildrenVisibility, updateBodyPlaceholderVisibility } from './toggle-lifecycle';
|
|
30
|
+
import { renderToggleItem, updateArrowState, updateChildrenVisibility, updateBodyPlaceholderVisibility, updateToggleEmptyState } from './toggle-lifecycle';
|
|
31
31
|
import { handleToggleEnter, handleToggleBackspace } from './toggle-keyboard';
|
|
32
32
|
import type { ToggleItemData, ToggleItemConfig } from './types';
|
|
33
33
|
|
|
@@ -127,9 +127,20 @@ export class ToggleItem implements BlockTool {
|
|
|
127
127
|
this._bodyPlaceholderElement = result.bodyPlaceholderElement;
|
|
128
128
|
this._childContainerElement = result.childContainerElement;
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Listen for input events from child blocks so the empty-state attribute
|
|
132
|
+
* (and the grayish arrow it drives) tracks what the user is typing in
|
|
133
|
+
* real time.
|
|
134
|
+
*/
|
|
135
|
+
this._childContainerElement.addEventListener('input', this.handleChildContainerInput);
|
|
136
|
+
|
|
130
137
|
return this._element;
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
private handleChildContainerInput = (): void => {
|
|
141
|
+
updateToggleEmptyState(this._element, this._childContainerElement);
|
|
142
|
+
};
|
|
143
|
+
|
|
133
144
|
public rendered(): void {
|
|
134
145
|
this.updateChildrenVisibility();
|
|
135
146
|
this.updateBodyPlaceholderVisibility();
|
|
@@ -322,6 +333,8 @@ export class ToggleItem implements BlockTool {
|
|
|
322
333
|
this._isOpen,
|
|
323
334
|
this.readOnly
|
|
324
335
|
);
|
|
336
|
+
|
|
337
|
+
updateToggleEmptyState(this._element, this._childContainerElement);
|
|
325
338
|
}
|
|
326
339
|
|
|
327
340
|
private handleBodyPlaceholderClick(): void {
|
|
@@ -13,6 +13,30 @@ import { TOGGLE_ATTR } from './constants';
|
|
|
13
13
|
import { buildToggleItem } from './dom-builder';
|
|
14
14
|
import type { ToggleDOMBuilderContext } from './dom-builder';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Sync the wrapper's data-blok-toggle-empty attribute to reflect whether the
|
|
18
|
+
* toggle's body has any visible text. Reads `textContent` off the child
|
|
19
|
+
* container directly so the state updates live as the user types (or deletes).
|
|
20
|
+
* The attribute drives the grayish arrow styling via the
|
|
21
|
+
* in-data-[blok-toggle-empty=true] Tailwind variant on ARROW_STYLES.
|
|
22
|
+
*
|
|
23
|
+
* @param wrapper - The toggle wrapper element that receives the attribute
|
|
24
|
+
* @param childContainer - The element that hosts the child block holders
|
|
25
|
+
*/
|
|
26
|
+
export const updateToggleEmptyState = (
|
|
27
|
+
wrapper: HTMLElement | null,
|
|
28
|
+
childContainer: HTMLElement | null
|
|
29
|
+
): void => {
|
|
30
|
+
if (wrapper === null) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const text = childContainer?.textContent ?? '';
|
|
35
|
+
const isEmpty = text.trim() === '';
|
|
36
|
+
|
|
37
|
+
wrapper.setAttribute(TOGGLE_ATTR.toggleEmpty, String(isEmpty));
|
|
38
|
+
};
|
|
39
|
+
|
|
16
40
|
/**
|
|
17
41
|
* Context for rendering a toggle item
|
|
18
42
|
*/
|