@jackuait/blok 0.21.1 → 0.23.0
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 +12 -1
- package/dist/blok.cjs +1 -1
- package/dist/blok.iife.js +4 -4
- package/dist/blok.mjs +2 -2
- package/dist/blok.umd.js +41 -41
- package/dist/chunks/{blok-DxI1HQZC.mjs → blok-BmckXjmW.mjs} +2570 -2269
- package/dist/chunks/{blok-DrMzzi_n.cjs → blok-D4WXeg3t.cjs} +5 -5
- package/dist/chunks/{constants-DGJO_Nr-.cjs → constants-BbCnmzAr.cjs} +1 -1
- package/dist/chunks/{constants-0pfHI3Vc.mjs → constants-tXKHkDAG.mjs} +1 -1
- package/dist/chunks/{tools-DqspF7V9.mjs → tools-oXMAa9zD.mjs} +1 -1
- package/dist/chunks/{tools-sHA-2cBM.cjs → tools-tG5_UaWz.cjs} +1 -1
- package/dist/full.cjs +1 -1
- package/dist/full.mjs +3 -3
- package/dist/react.cjs +1 -1
- package/dist/react.mjs +69 -42
- package/dist/tools.cjs +1 -1
- package/dist/tools.mjs +2 -2
- package/package.json +1 -1
- package/src/components/modules/paste/index.ts +33 -6
- package/src/components/modules/paste/next-space-blocks.ts +520 -0
- package/src/components/modules/paste/notion-blocks-v3.ts +28 -4
- package/src/react/BlokEditor.tsx +63 -0
- package/src/react/index.ts +2 -0
- package/src/react/types.ts +13 -3
- package/src/react/useBlok.ts +18 -0
- package/types/react.d.ts +38 -3
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for buildin.ai's proprietary lossless clipboard flavours
|
|
3
|
+
* `text/next-space-blocks` (multi-block copy) and `text/next-space-content`
|
|
4
|
+
* (single-block copy).
|
|
5
|
+
*
|
|
6
|
+
* When you copy blocks in buildin.ai's web app, the clipboard carries — beside
|
|
7
|
+
* `text/plain` and `text/html` — a JSON envelope under these MIME types that
|
|
8
|
+
* preserves EVERY block's state (type, level, checked, language, icon, colour,
|
|
9
|
+
* nesting…). The HTML flavour cannot carry all of that, so this JSON is the
|
|
10
|
+
* high-fidelity migration source for buildin-web → Blok-web paste, mirroring
|
|
11
|
+
* what {@link parseNotionBlocksV3} does for Notion.
|
|
12
|
+
*
|
|
13
|
+
* Envelope: `{ blocks: [{ id, subTree: { <uuid>: node } }], pageId, fromType }`.
|
|
14
|
+
* The `blocks` array is ALREADY in document order; every top-level root points
|
|
15
|
+
* its `parentId` at `pageId` (itself absent from the payload, so roots have no
|
|
16
|
+
* emitted parent). `node.subNodes` is the ordered child-uuid array. A node's
|
|
17
|
+
* type is a NUMBER and its text is the concatenation of `data.segments[*].text`.
|
|
18
|
+
*
|
|
19
|
+
* The output is a flat array of {@link NextSpaceParsedBlock} (the same
|
|
20
|
+
* `{ id, tool, data, parentId }` shape the Blok clipboard handler consumes),
|
|
21
|
+
* which the two-pass `BlokDataHandler` builder turns into a nested block tree.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { COLOR_PRESETS } from '../../shared/color-presets';
|
|
25
|
+
import { DEFAULT_EMOJI } from '../../../tools/callout/constants';
|
|
26
|
+
import { DEFAULT_LANGUAGE, LANGUAGES } from '../../../tools/code/constants';
|
|
27
|
+
import { matchEmbedService } from '../../../tools/link/registry';
|
|
28
|
+
|
|
29
|
+
// NOTE: notion-blocks-v3 also uses `colorVarName` for its inline <mark> colour
|
|
30
|
+
// styling. buildin's captured clipboard carries no inline colour marks (every
|
|
31
|
+
// enhancer is `{}`), so the plain-text mapping does not need it — wire it in
|
|
32
|
+
// alongside the TODO(inline-marks) work in `segmentsToHtml` once formatted
|
|
33
|
+
// content is captured. (Block-level callout colours ARE handled, via
|
|
34
|
+
// `normalizeBuildinColor` below.)
|
|
35
|
+
|
|
36
|
+
/** MIME flavours that carry buildin.ai's lossless block envelope. */
|
|
37
|
+
export const NEXT_SPACE_MIMES = ['text/next-space-blocks', 'text/next-space-content'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A single Blok-ready block produced from the buildin envelope.
|
|
41
|
+
* Shape-compatible with the clipboard handler's `BlokClipboardBlock`.
|
|
42
|
+
*/
|
|
43
|
+
export interface NextSpaceParsedBlock {
|
|
44
|
+
id: string;
|
|
45
|
+
tool: string;
|
|
46
|
+
data: Record<string, unknown>;
|
|
47
|
+
parentId?: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A buildin block node (loosely typed — most fields are optional). */
|
|
51
|
+
interface NextSpaceNode {
|
|
52
|
+
uuid: string;
|
|
53
|
+
type: number;
|
|
54
|
+
title?: string;
|
|
55
|
+
backgroundColor?: string;
|
|
56
|
+
textColor?: string;
|
|
57
|
+
data?: Record<string, unknown>;
|
|
58
|
+
subNodes?: string[];
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Result of mapping one node: tool + data. */
|
|
63
|
+
interface Mapped {
|
|
64
|
+
tool: string;
|
|
65
|
+
data: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a `text/next-space-blocks` / `text/next-space-content` payload into a
|
|
70
|
+
* flat array of Blok-ready blocks. Returns `null` when the payload is not valid
|
|
71
|
+
* JSON or does not look like the buildin envelope (so callers fall back to the
|
|
72
|
+
* HTML path).
|
|
73
|
+
*/
|
|
74
|
+
export function parseNextSpaceBlocks(json: string): NextSpaceParsedBlock[] | null {
|
|
75
|
+
const parsed = safeJsonParse(json);
|
|
76
|
+
|
|
77
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const blocks = (parsed as { blocks?: unknown }).blocks;
|
|
82
|
+
|
|
83
|
+
if (!Array.isArray(blocks) || blocks.length === 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Merge every entry's `subTree` map into one uuid → node lookup, and remember
|
|
88
|
+
// the top-level (selected) order from the `blocks` array.
|
|
89
|
+
const byId = new Map<string, NextSpaceNode>();
|
|
90
|
+
|
|
91
|
+
blocks.forEach((entry) => {
|
|
92
|
+
const subTree = (entry as { subTree?: Record<string, NextSpaceNode> })?.subTree;
|
|
93
|
+
|
|
94
|
+
if (subTree === undefined || subTree === null || typeof subTree !== 'object') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
Object.values(subTree).forEach((value) => {
|
|
99
|
+
if (value !== undefined && value !== null && typeof value === 'object' && typeof value.uuid === 'string') {
|
|
100
|
+
byId.set(value.uuid, value);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Require envelope structure: at least one resolvable node.
|
|
106
|
+
if (byId.size === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const topLevelOrder = blocks
|
|
111
|
+
.map((entry) => (entry as { id?: string })?.id)
|
|
112
|
+
.filter((id): id is string => typeof id === 'string');
|
|
113
|
+
|
|
114
|
+
const result: NextSpaceParsedBlock[] = [];
|
|
115
|
+
const visited = new Set<string>();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Depth-first emit in document order. `parentId` is the uuid of the nearest
|
|
119
|
+
* EMITTED ancestor (null at the top).
|
|
120
|
+
*/
|
|
121
|
+
const walk = (id: string, parentId: string | null): void => {
|
|
122
|
+
if (visited.has(id)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const node = byId.get(id);
|
|
127
|
+
|
|
128
|
+
if (node === undefined) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
visited.add(id);
|
|
133
|
+
|
|
134
|
+
// Tables expand into a grid block plus one paragraph block per cell, and
|
|
135
|
+
// consume their `type 28` row children — handle them before the leaf map.
|
|
136
|
+
if (node.type === 27) {
|
|
137
|
+
expandTable(node, parentId, byId, visited, result);
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const mapped = mapNode(node);
|
|
143
|
+
|
|
144
|
+
if (mapped === null) {
|
|
145
|
+
// Type-28 table rows are consumed by `expandTable`; a stray one is a no-op.
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const block: NextSpaceParsedBlock = { id, tool: mapped.tool, data: mapped.data };
|
|
150
|
+
|
|
151
|
+
if (parentId !== null) {
|
|
152
|
+
block.parentId = parentId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
result.push(block);
|
|
156
|
+
|
|
157
|
+
// Blok's callout keeps its body in CHILD blocks (CalloutData has no `text`
|
|
158
|
+
// field), so the callout's inline text — which buildin stores on the callout
|
|
159
|
+
// node itself — becomes a child paragraph, matching what a native callout
|
|
160
|
+
// copy carries. (Any `subNodes` are appended after it below.)
|
|
161
|
+
if (node.type === 13) {
|
|
162
|
+
const body = segmentsToHtml((node.data ?? {}).segments);
|
|
163
|
+
|
|
164
|
+
if (body.length > 0) {
|
|
165
|
+
result.push({ id: `${id}:callout-body`, tool: 'paragraph', data: { text: body }, parentId: id });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const children = Array.isArray(node.subNodes) ? node.subNodes : [];
|
|
170
|
+
|
|
171
|
+
children.forEach((childId) => walk(childId, id));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
topLevelOrder.forEach((id) => walk(id, null));
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Expand a buildin `table` (type 27) into a Blok table block (a grid of cell
|
|
181
|
+
* references) plus one paragraph block per cell — Blok stores cell content as
|
|
182
|
+
* child blocks referenced by id, not inline. The owning row nodes (type 28) are
|
|
183
|
+
* marked visited so the walker never emits them separately.
|
|
184
|
+
*/
|
|
185
|
+
function expandTable(
|
|
186
|
+
table: NextSpaceNode,
|
|
187
|
+
parentId: string | null,
|
|
188
|
+
byId: Map<string, NextSpaceNode>,
|
|
189
|
+
visited: Set<string>,
|
|
190
|
+
result: NextSpaceParsedBlock[]
|
|
191
|
+
): void {
|
|
192
|
+
const tableId = table.uuid;
|
|
193
|
+
const data = table.data ?? {};
|
|
194
|
+
const format = isPlainObject(data.format) ? data.format : {};
|
|
195
|
+
const rowIds = (Array.isArray(table.subNodes) ? table.subNodes : []).filter(
|
|
196
|
+
(rowId): rowId is string => typeof rowId === 'string'
|
|
197
|
+
);
|
|
198
|
+
const columnOrder = Array.isArray(format.tableBlockColumnOrder)
|
|
199
|
+
? format.tableBlockColumnOrder.filter((columnId): columnId is string => typeof columnId === 'string')
|
|
200
|
+
: [];
|
|
201
|
+
const cells: NextSpaceParsedBlock[] = [];
|
|
202
|
+
const content: { blocks: string[] }[][] = rowIds.map((rowId) => {
|
|
203
|
+
visited.add(rowId);
|
|
204
|
+
|
|
205
|
+
const rowData = byId.get(rowId)?.data ?? {};
|
|
206
|
+
const collectionProperties = isPlainObject(rowData.collectionProperties) ? rowData.collectionProperties : {};
|
|
207
|
+
|
|
208
|
+
return columnOrder.map((columnId) => {
|
|
209
|
+
const cellId = `${rowId}:${columnId}`;
|
|
210
|
+
|
|
211
|
+
cells.push({ id: cellId, tool: 'paragraph', data: { text: segmentsToHtml(collectionProperties[columnId]) }, parentId: tableId });
|
|
212
|
+
|
|
213
|
+
return { blocks: [cellId] };
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const tableBlock: NextSpaceParsedBlock = {
|
|
218
|
+
id: tableId,
|
|
219
|
+
tool: 'table',
|
|
220
|
+
data: {
|
|
221
|
+
withHeadings: format.tableBlockRowHeader === true,
|
|
222
|
+
withHeadingColumn: format.tableBlockColumnHeader === true,
|
|
223
|
+
content,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (parentId !== null) {
|
|
228
|
+
tableBlock.parentId = parentId;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
result.push(tableBlock);
|
|
232
|
+
cells.forEach((cell) => result.push(cell));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Map one buildin node to a Blok tool + data, or `null` to skip (table rows). */
|
|
236
|
+
function mapNode(node: NextSpaceNode): Mapped | null {
|
|
237
|
+
const data = node.data ?? {};
|
|
238
|
+
const text = segmentsToHtml(data.segments);
|
|
239
|
+
|
|
240
|
+
switch (node.type) {
|
|
241
|
+
case 1:
|
|
242
|
+
return { tool: 'paragraph', data: { text } };
|
|
243
|
+
case 3:
|
|
244
|
+
return { tool: 'list', data: { text, style: 'checklist', checked: data.checked === true } };
|
|
245
|
+
case 4:
|
|
246
|
+
return { tool: 'list', data: { text, style: 'unordered' } };
|
|
247
|
+
case 5:
|
|
248
|
+
return { tool: 'list', data: { text, style: 'ordered' } };
|
|
249
|
+
case 7:
|
|
250
|
+
return { tool: 'header', data: { text, level: data.level } };
|
|
251
|
+
case 38:
|
|
252
|
+
return { tool: 'header', data: { text, level: data.level, isToggleable: true, isOpen: true } };
|
|
253
|
+
case 6:
|
|
254
|
+
return { tool: 'toggle', data: { text, isOpen: true } };
|
|
255
|
+
case 9:
|
|
256
|
+
return { tool: 'divider', data: {} };
|
|
257
|
+
case 12:
|
|
258
|
+
return { tool: 'quote', data: { text } };
|
|
259
|
+
case 13:
|
|
260
|
+
return mapCallout(node, data);
|
|
261
|
+
case 25:
|
|
262
|
+
return {
|
|
263
|
+
tool: 'code',
|
|
264
|
+
data: { code: plainText(data.segments), language: mapLanguageName(languageOf(data.format)), lineNumbers: false },
|
|
265
|
+
};
|
|
266
|
+
case 23:
|
|
267
|
+
return { tool: 'code', data: { code: plainText(data.segments), language: 'latex', lineNumbers: false } };
|
|
268
|
+
case 10:
|
|
269
|
+
return { tool: 'column_list', data: {} };
|
|
270
|
+
case 11: {
|
|
271
|
+
const ratio = data.columnRatio;
|
|
272
|
+
|
|
273
|
+
return { tool: 'column', data: typeof ratio === 'number' && ratio !== 1 ? { widthRatio: ratio } : {} };
|
|
274
|
+
}
|
|
275
|
+
case 14:
|
|
276
|
+
return mapMedia(node, data, text);
|
|
277
|
+
case 21:
|
|
278
|
+
return mapEmbedOrBookmark(data, text);
|
|
279
|
+
case 28:
|
|
280
|
+
// Consumed by `expandTable`; a stray row should never surface as a block.
|
|
281
|
+
return null;
|
|
282
|
+
default:
|
|
283
|
+
// Unmapped types fall back to a paragraph carrying their text.
|
|
284
|
+
return { tool: 'paragraph', data: { text } };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Map a buildin callout (type 13) to callout DATA only — colour names live on
|
|
290
|
+
* the node (not `data`). The body text is emitted SEPARATELY as a child
|
|
291
|
+
* paragraph by the walker (Blok callouts store their body as child blocks), so
|
|
292
|
+
* no `text` field is emitted here (the callout tool would silently discard it).
|
|
293
|
+
*/
|
|
294
|
+
function mapCallout(node: NextSpaceNode, data: Record<string, unknown>): Mapped {
|
|
295
|
+
const icon = isPlainObject(data.icon) ? data.icon : {};
|
|
296
|
+
const emoji = typeof icon.value === 'string' && icon.value.length > 0 ? icon.value : DEFAULT_EMOJI;
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
tool: 'callout',
|
|
300
|
+
data: { emoji, textColor: normalizeBuildinColor(node.textColor), backgroundColor: normalizeBuildinColor(node.backgroundColor) },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The set of colour preset names Blok recognises (its callout/marker palette). */
|
|
305
|
+
const BLOK_COLOR_NAMES = new Set(COLOR_PRESETS.map((preset) => preset.name));
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Map a buildin colour NAME to a valid Blok preset name, or `null`. buildin uses
|
|
309
|
+
* British `grey`; Blok's preset is American `gray` — passing `grey` through
|
|
310
|
+
* verbatim yields an undefined `var(--blok-color-grey-bg)` that silently drops
|
|
311
|
+
* the colour (and the callout's border). Names outside Blok's palette collapse
|
|
312
|
+
* to `null` rather than render broken.
|
|
313
|
+
*/
|
|
314
|
+
function normalizeBuildinColor(value: unknown): string | null {
|
|
315
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const name = value.toLowerCase() === 'grey' ? 'gray' : value.toLowerCase();
|
|
320
|
+
|
|
321
|
+
return BLOK_COLOR_NAMES.has(name) ? name : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The `language` string from a code block's `data.format`, or `undefined`. */
|
|
325
|
+
function languageOf(format: unknown): unknown {
|
|
326
|
+
return isPlainObject(format) ? format.language : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Map a buildin media node (type 14) to image / video / audio / file by its
|
|
331
|
+
* `data.display` discriminator. The binary lives behind buildin's signed CDN —
|
|
332
|
+
* see {@link cdnUrl} — so the URL is best-effort.
|
|
333
|
+
*/
|
|
334
|
+
function mapMedia(node: NextSpaceNode, data: Record<string, unknown>, text: string): Mapped {
|
|
335
|
+
const ossName = typeof data.ossName === 'string' ? data.ossName : '';
|
|
336
|
+
const title = typeof node.title === 'string' ? node.title : '';
|
|
337
|
+
const fileName = text.length > 0 ? text : title;
|
|
338
|
+
|
|
339
|
+
if (ossName.length === 0) {
|
|
340
|
+
// No CDN object — keep the filename as a paragraph rather than drop it.
|
|
341
|
+
return { tool: 'paragraph', data: { text: fileName } };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const url = cdnUrl(ossName);
|
|
345
|
+
const display = typeof data.display === 'string' ? data.display : 'file';
|
|
346
|
+
|
|
347
|
+
if (display === 'image') {
|
|
348
|
+
const imageData: Record<string, unknown> = { url };
|
|
349
|
+
const alignment = gravityAlignment(data.format);
|
|
350
|
+
|
|
351
|
+
if (alignment !== null) {
|
|
352
|
+
imageData.alignment = alignment;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { tool: 'image', data: imageData };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (display === 'video') {
|
|
359
|
+
return { tool: 'video', data: { url } };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (display === 'audio') {
|
|
363
|
+
const audioData: Record<string, unknown> = { url };
|
|
364
|
+
|
|
365
|
+
if (fileName.length > 0) {
|
|
366
|
+
audioData.title = fileName;
|
|
367
|
+
audioData.fileName = fileName;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { tool: 'audio', data: audioData };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 'file' and any unknown display.
|
|
374
|
+
const fileData: Record<string, unknown> = { url };
|
|
375
|
+
|
|
376
|
+
if (fileName.length > 0) {
|
|
377
|
+
fileData.fileName = fileName;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { tool: 'file', data: fileData };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Best-effort CDN URL for a buildin object. NOTE: the CDN signing token is NOT
|
|
385
|
+
* present in the clipboard JSON, so this unsigned URL may not load directly;
|
|
386
|
+
* faithful re-hosting of buildin media is a follow-up.
|
|
387
|
+
*/
|
|
388
|
+
function cdnUrl(ossName: string): string {
|
|
389
|
+
return `https://cdn2.buildin.ai/${ossName}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Map a buildin `data.format.contentGravity` to a left/right image alignment. */
|
|
393
|
+
function gravityAlignment(format: unknown): 'left' | 'right' | null {
|
|
394
|
+
if (!isPlainObject(format)) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (format.contentGravity === 'LEFT') {
|
|
399
|
+
return 'left';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (format.contentGravity === 'RIGHT') {
|
|
403
|
+
return 'right';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Map a buildin embed/bookmark node (type 21) to a resolved embed (provider
|
|
411
|
+
* match), a bookmark carrying the live URL, or a text fallback.
|
|
412
|
+
*/
|
|
413
|
+
function mapEmbedOrBookmark(data: Record<string, unknown>, text: string): Mapped {
|
|
414
|
+
const url = firstHttpUrl(data.link);
|
|
415
|
+
|
|
416
|
+
if (url === null) {
|
|
417
|
+
return { tool: 'paragraph', data: { text } };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const embed = resolveEmbed(url);
|
|
421
|
+
|
|
422
|
+
if (embed !== null) {
|
|
423
|
+
return embed;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const bookmark: Record<string, unknown> = { url };
|
|
427
|
+
const title = plainText(data.linkInfo);
|
|
428
|
+
|
|
429
|
+
if (title.length > 0) {
|
|
430
|
+
bookmark.title = title;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { tool: 'bookmark', data: bookmark };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Resolve a URL against the embed registry into Blok embed data, or `null`. */
|
|
437
|
+
function resolveEmbed(url: string): Mapped | null {
|
|
438
|
+
const match = matchEmbedService(url);
|
|
439
|
+
|
|
440
|
+
if (match === null) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return { tool: 'embed', data: { service: match.service, source: url, embed: match.embedUrl, kind: match.kind } };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Pre-built lower-cased language-name → language-id lookup for code blocks. */
|
|
448
|
+
const LANGUAGE_BY_NAME = new Map(LANGUAGES.map((l) => [l.name.toLowerCase(), l.id]));
|
|
449
|
+
|
|
450
|
+
/** Map a buildin language name (e.g. "YAML") to a Blok language id. */
|
|
451
|
+
function mapLanguageName(name: unknown): string {
|
|
452
|
+
return LANGUAGE_BY_NAME.get(String(name).toLowerCase()) ?? DEFAULT_LANGUAGE;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Parse JSON, returning `null` instead of throwing on malformed input. */
|
|
456
|
+
function safeJsonParse(json: string): unknown {
|
|
457
|
+
try {
|
|
458
|
+
return JSON.parse(json);
|
|
459
|
+
} catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Convert a buildin `segments` array into Blok inline HTML.
|
|
466
|
+
*
|
|
467
|
+
* The captured fixture has NO inline formatting — every `segment.enhancer` is
|
|
468
|
+
* `{}` — so this currently joins the HTML-escaped `text` of each segment, which
|
|
469
|
+
* is correct and lossless for plain text.
|
|
470
|
+
*
|
|
471
|
+
* EXTENSION POINT: inline marks (bold / italic / link / colour) live in
|
|
472
|
+
* `segment.enhancer`; to render them, branch here on the enhancer's keys and
|
|
473
|
+
* wrap the escaped text accordingly (mirroring notion-blocks-v3's `segmentHtml`).
|
|
474
|
+
* TODO(inline-marks): the enhancer key vocabulary is unknown until a FORMATTED
|
|
475
|
+
* buildin clipboard is captured — do NOT guess the key names.
|
|
476
|
+
*/
|
|
477
|
+
function segmentsToHtml(segments: unknown): string {
|
|
478
|
+
if (!Array.isArray(segments)) {
|
|
479
|
+
return '';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return segments
|
|
483
|
+
.map((segment) => (isPlainObject(segment) && typeof segment.text === 'string' ? escapeHtml(segment.text) : ''))
|
|
484
|
+
.join('');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Concatenate a buildin `segments` array into raw plain text (no escaping). */
|
|
488
|
+
function plainText(segments: unknown): string {
|
|
489
|
+
if (!Array.isArray(segments)) {
|
|
490
|
+
return '';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return segments
|
|
494
|
+
.map((segment) => (isPlainObject(segment) && typeof segment.text === 'string' ? segment.text : ''))
|
|
495
|
+
.join('');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** First candidate that resolves to an http(s) URL. */
|
|
499
|
+
function firstHttpUrl(...candidates: unknown[]): string | null {
|
|
500
|
+
for (const candidate of candidates) {
|
|
501
|
+
const raw = typeof candidate === 'string' ? candidate : plainText(candidate);
|
|
502
|
+
const trimmed = raw.trim();
|
|
503
|
+
|
|
504
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
505
|
+
return trimmed;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Whether a value is a non-null, non-array object. */
|
|
513
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
514
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Escape `&`, `<`, `>` for HTML text content. */
|
|
518
|
+
function escapeHtml(text: string): string {
|
|
519
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
520
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* correctly-nested block tree.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { colorVarName } from '../../shared/color-presets';
|
|
23
|
+
import { COLOR_PRESETS, colorVarName } from '../../shared/color-presets';
|
|
24
24
|
import { DEFAULT_EMOJI } from '../../../tools/callout/constants';
|
|
25
25
|
import { DEFAULT_LANGUAGE, LANGUAGES } from '../../../tools/code/constants';
|
|
26
26
|
import { matchEmbedService } from '../../../tools/link/registry';
|
|
@@ -151,6 +151,17 @@ export function parseNotionBlocksV3(json: string): NotionParsedBlock[] | null {
|
|
|
151
151
|
|
|
152
152
|
result.push(block);
|
|
153
153
|
|
|
154
|
+
// Blok's callout keeps its body in CHILD blocks, so the callout's own inline
|
|
155
|
+
// title line becomes a child paragraph (emitted before its `content`
|
|
156
|
+
// children), matching what a native callout copy carries.
|
|
157
|
+
if (value.type === 'callout') {
|
|
158
|
+
const calloutTitle = richText(value.properties?.title, byId);
|
|
159
|
+
|
|
160
|
+
if (calloutTitle.length > 0) {
|
|
161
|
+
result.push({ id: `${id}:callout-body`, tool: 'paragraph', data: { text: calloutTitle }, parentId: id });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
children.forEach((childId) => walk(childId, id));
|
|
155
166
|
};
|
|
156
167
|
|
|
@@ -328,7 +339,10 @@ function mapValue(value: NotionValue, byId: Map<string, NotionValue>): Mapped |
|
|
|
328
339
|
const emoji = typeof format.page_icon === 'string' && format.page_icon.length > 0 ? format.page_icon : DEFAULT_EMOJI;
|
|
329
340
|
const { textColor, backgroundColor } = parseBlockColor(format.block_color);
|
|
330
341
|
|
|
331
|
-
|
|
342
|
+
// No inline `text`: Blok's callout stores its body in CHILD blocks, so the
|
|
343
|
+
// callout's title line is emitted as a child paragraph by `walk` (and a
|
|
344
|
+
// stray `data.text` would be silently discarded by the callout tool).
|
|
345
|
+
return { tool: 'callout', data: { emoji, textColor, backgroundColor } };
|
|
332
346
|
}
|
|
333
347
|
case 'page':
|
|
334
348
|
// A `page` block inside content is a SUB-PAGE reference: its body lives on
|
|
@@ -862,11 +876,21 @@ function buildColourStyle(colourFlags: unknown[]): string {
|
|
|
862
876
|
return parts.join('; ');
|
|
863
877
|
}
|
|
864
878
|
|
|
865
|
-
/**
|
|
879
|
+
/** The set of colour preset names Blok recognises (its callout/marker palette). */
|
|
880
|
+
const BLOK_COLOR_NAMES = new Set(COLOR_PRESETS.map((preset) => preset.name));
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Map a Notion `block_color` to callout `{ textColor, backgroundColor }` names.
|
|
884
|
+
* Names outside Blok's preset palette collapse to `null`: the callout tool
|
|
885
|
+
* builds `var(--blok-color-<name>-…)` from the name verbatim, so an unrecognised
|
|
886
|
+
* name yields an undefined custom property that silently drops the colour AND
|
|
887
|
+
* the callout's border. Notion's palette already aligns with Blok's, so this is
|
|
888
|
+
* a safety clamp rather than a remap.
|
|
889
|
+
*/
|
|
866
890
|
function parseBlockColor(token: unknown): { textColor: string | null; backgroundColor: string | null } {
|
|
867
891
|
const colour = parseNotionColour(token);
|
|
868
892
|
|
|
869
|
-
if (colour === null) {
|
|
893
|
+
if (colour === null || !BLOK_COLOR_NAMES.has(colour.name)) {
|
|
870
894
|
return { textColor: null, backgroundColor: null };
|
|
871
895
|
}
|
|
872
896
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useEffect, useRef, type DependencyList } from 'react';
|
|
2
|
+
import { useBlok } from './useBlok';
|
|
3
|
+
import { BlokContent } from './BlokContent';
|
|
4
|
+
import type { Blok } from '@/types';
|
|
5
|
+
import type { UseBlokConfig } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for the all-in-one BlokEditor component.
|
|
9
|
+
* Accepts every useBlok config prop (except `onReady`, re-typed below to receive
|
|
10
|
+
* the ready instance), plus container `className`/`data-testid` and an optional
|
|
11
|
+
* `deps` list. When any value in `deps` changes, the editor is destroyed and
|
|
12
|
+
* recreated (use this when `tools` or other structural config changes).
|
|
13
|
+
*
|
|
14
|
+
* `data` is uncontrolled (seed-only): it sets the INITIAL content. After mount the
|
|
15
|
+
* editor owns the document — passing a new `data` reference does NOT reload it.
|
|
16
|
+
* Read content via `onChange` or the ref (`ref.current.save()`); replace it
|
|
17
|
+
* imperatively via `ref.current.render(newData)`.
|
|
18
|
+
*/
|
|
19
|
+
export interface BlokEditorProps extends Omit<UseBlokConfig, 'onReady'> {
|
|
20
|
+
/** When any value changes, the editor is destroyed and recreated. */
|
|
21
|
+
deps?: DependencyList;
|
|
22
|
+
/** Class name applied to the editor container element. */
|
|
23
|
+
className?: string;
|
|
24
|
+
/** Test id forwarded to the editor container element (via data-testid). */
|
|
25
|
+
'data-testid'?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Called once the editor is ready, with the live Blok instance. Fires after the
|
|
28
|
+
* forwarded ref is committed, so `ref.current` is also populated at this point.
|
|
29
|
+
*/
|
|
30
|
+
onReady?: (editor: Blok) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The recommended way to embed Blok in React. Internally wires `useBlok` and
|
|
35
|
+
* `BlokContent`, and forwards a ref to the live `Blok` instance.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const ref = useRef<Blok | null>(null);
|
|
40
|
+
* <BlokEditor ref={ref} tools={tools} data={data} theme={theme} onReady={(e) => e.focus()} />;
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const BlokEditor = forwardRef<Blok | null, BlokEditorProps>(
|
|
44
|
+
function BlokEditor({ deps, className, onReady, 'data-testid': dataTestId, ...config }, ref) {
|
|
45
|
+
const editor = useBlok(config, deps);
|
|
46
|
+
|
|
47
|
+
useImperativeHandle<Blok | null, Blok | null>(ref, () => editor, [editor]);
|
|
48
|
+
|
|
49
|
+
// Own onReady here instead of forwarding it to the core config: the core
|
|
50
|
+
// onReady fires before useImperativeHandle commits the ref. This passive
|
|
51
|
+
// effect runs AFTER the commit, so both the instance argument and ref.current
|
|
52
|
+
// are reliably populated.
|
|
53
|
+
const onReadyRef = useRef(onReady);
|
|
54
|
+
onReadyRef.current = onReady;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (editor !== null) {
|
|
57
|
+
onReadyRef.current?.(editor);
|
|
58
|
+
}
|
|
59
|
+
}, [editor]);
|
|
60
|
+
|
|
61
|
+
return <BlokContent editor={editor} className={className} data-testid={dataTestId} />;
|
|
62
|
+
}
|
|
63
|
+
);
|
package/src/react/index.ts
CHANGED
package/src/react/types.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import type { BlokConfig, Blok } from '@/types';
|
|
1
|
+
import type { BlokConfig, Blok, EditorWidth } from '@/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Configuration for useBlok hook.
|
|
5
|
-
* Same as BlokConfig but without `holder`
|
|
5
|
+
* Same as BlokConfig but without `holder` (managed by BlokContent), plus a
|
|
6
|
+
* React-only reactive `width` prop.
|
|
7
|
+
*
|
|
8
|
+
* Reactive props (sync after mount without recreation):
|
|
9
|
+
* - `readOnly` — calls `editor.readOnly.set(value)`
|
|
10
|
+
* - `autofocus` — calls `editor.focus()` when changed to true
|
|
11
|
+
* - `theme` — calls `editor.theme.set(value)`
|
|
12
|
+
* - `width` — calls `editor.width.set(value)`
|
|
6
13
|
*/
|
|
7
|
-
export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
|
|
14
|
+
export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
|
|
15
|
+
/** Editor content width mode. Synced reactively after mount via `editor.width.set()`. */
|
|
16
|
+
width?: EditorWidth;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
20
|
* Props for the BlokContent component.
|