@jackuait/blok 0.21.1 → 0.22.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/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-DrMzzi_n.cjs → blok-Blnd71Ww.cjs} +5 -5
- package/dist/chunks/{blok-DxI1HQZC.mjs → blok-S6c68iVJ.mjs} +2570 -2269
- package/dist/chunks/{constants-DGJO_Nr-.cjs → constants-BIqDMhRj.cjs} +1 -1
- package/dist/chunks/{constants-0pfHI3Vc.mjs → constants-BvxJIgSQ.mjs} +1 -1
- package/dist/chunks/{tools-DqspF7V9.mjs → tools-B89FUeNz.mjs} +1 -1
- package/dist/chunks/{tools-sHA-2cBM.cjs → tools-DTjC0C5o.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 +1 -1
- 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/dist/full.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/blok-Blnd71Ww.cjs`),t=require(`./chunks/constants-BIqDMhRj.cjs`),n=require(`./chunks/objectSpread2-iNkE6aJh.cjs`),r=require(`./chunks/tools-DTjC0C5o.cjs`);var i={paragraph:{class:r.k,inlineToolbar:!0,config:{preserveBlank:!0}},header:{class:r.O,inlineToolbar:!0},list:{class:r.D,inlineToolbar:!0}},a=n.t(n.t({},i),{},{bold:{class:r.l},italic:{class:r.c},marker:{class:r.o},underline:{class:r.a},strikethrough:{class:r.i},link:{class:r.s}});exports.Blok=e.t,exports.Bold=r.l,exports.DATA_ATTR=t.ja,exports.Header=r.O,exports.Italic=r.c,exports.Link=r.s,exports.List=r.D,exports.Marker=r.o,exports.Paragraph=r.k,exports.Strikethrough=r.i,exports.Toggle=r.T,exports.Underline=r.a,exports.allTools=a,exports.defaultBlockTools=r.t,exports.defaultInlineTools=r.n,exports.defaultTools=i,exports.version=e.n;
|
package/dist/full.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { n as e, t } from "./chunks/blok-
|
|
2
|
-
import { ja as n } from "./chunks/constants-
|
|
1
|
+
import { n as e, t } from "./chunks/blok-S6c68iVJ.mjs";
|
|
2
|
+
import { ja as n } from "./chunks/constants-BvxJIgSQ.mjs";
|
|
3
3
|
import { t as r } from "./chunks/objectSpread2-jlBcdYeb.mjs";
|
|
4
|
-
import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-
|
|
4
|
+
import { D as i, O as a, T as o, a as s, c, i as l, k as u, l as d, n as f, o as p, s as m, t as h } from "./chunks/tools-B89FUeNz.mjs";
|
|
5
5
|
//#region src/full.ts
|
|
6
6
|
var g = {
|
|
7
7
|
paragraph: {
|
package/dist/react.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`}),require(`./chunks/chunk-BL1yB9zm.cjs`);const e=require(`./chunks/blok-Blnd71Ww.cjs`),t=require(`./chunks/objectSpread2-iNkE6aJh.cjs`);let n=require(`react`),r=require(`react/jsx-runtime`);var i=new WeakMap;function a(e,t){i.set(e,t)}function o(e){return i.get(e)}function s(e){i.delete(e)}function c(r,i){let[o,c]=(0,n.useState)(null),u=(0,n.useRef)(r);u.current=r;let d=(0,n.useRef)({editor:null,holder:null,destroyTimeout:null,isDestroyed:!1,depsToken:null}),f=(0,n.useMemo)(()=>({}),i==null?[]:i);(0,n.useEffect)(()=>{if(typeof window>`u`)return;let n=d.current;if(n.destroyTimeout!==null&&(clearTimeout(n.destroyTimeout),n.destroyTimeout=null),n.editor!==null&&!n.isDestroyed&&n.depsToken===f)return c(n.editor),()=>{l(n,c)};if(n.editor!==null&&!n.isDestroyed){s(n.editor);try{n.editor.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}let r=document.createElement(`div`);n.holder=r,n.isDestroyed=!1,n.depsToken=f;let i=u.current,o=new e.t(t.t(t.t({},i),{},{holder:r,onReady:()=>{var e,t;(e=(t=u.current).onReady)==null||e.call(t)},onChange:(...e)=>{var t,n;(t=(n=u.current).onChange)==null||t.call(n,...e)}}));return n.editor=o,a(o,r),o.isReady.then(()=>{n.editor===o&&!n.isDestroyed&&c(o)}).catch(()=>{if(n.editor===o&&!n.isDestroyed){s(o);try{o.destroy()}catch(e){}n.editor=null,n.holder=null,n.isDestroyed=!0,c(null)}}),()=>{l(n,c)}},[f]);let{readOnly:p}=r;(0,n.useEffect)(()=>{o!==null&&o.readOnly.set(p==null?!1:p)},[o,p]);let{autofocus:m}=r;return(0,n.useEffect)(()=>{o===null||!m||o.focus()},[o,m]),o}function l(e,t){e.destroyTimeout=setTimeout(()=>{if(e.editor!==null){s(e.editor);try{e.editor.destroy()}catch(e){}e.editor=null,e.holder=null,e.isDestroyed=!0,e.destroyTimeout=null,t(null)}},0)}var u=[`editor`],d=(0,n.forwardRef)(function(i,a){let{editor:s}=i,c=e.i(i,u),l=(0,n.useRef)(null);return(0,n.useEffect)(()=>{if(s===null||l.current===null)return;let e=o(s);if(e!==void 0)return l.current.appendChild(e),()=>{e.remove()}},[s]),(0,r.jsx)(`div`,t.t({ref:e=>{if(l.current=e,typeof a==`function`)a(e);else if(a!=null){let t=a;t.current=e}}},c))});exports.BlokContent=d,exports.useBlok=c;
|
package/dist/react.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as e, t } from "./chunks/blok-
|
|
1
|
+
import { i as e, t } from "./chunks/blok-S6c68iVJ.mjs";
|
|
2
2
|
import { t as n } from "./chunks/objectSpread2-jlBcdYeb.mjs";
|
|
3
3
|
import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
|
|
4
4
|
import { jsx as c } from "react/jsx-runtime";
|
package/dist/tools.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunks/constants-BIqDMhRj.cjs`),t=require(`./chunks/tools-DTjC0C5o.cjs`);exports.Audio=t.g,exports.Bold=t.l,exports.Bookmark=t.u,exports.Callout=t.w,exports.Code=t.y,exports.Column=t.p,exports.ColumnList=t.m,exports.Columns=t.f,exports.Convert=e.m,exports.Database=t.C,exports.DatabaseRow=t.S,exports.Divider=t.x,exports.Embed=t.d,exports.File=t._,exports.Header=t.O,exports.Image=t.v,exports.InlineCode=t.r,exports.Italic=t.c,exports.Link=t.s,exports.List=t.D,exports.Marker=t.o,exports.Paragraph=t.k,exports.Quote=t.b,exports.Strikethrough=t.i,exports.Table=t.E,exports.Toggle=t.T,exports.Underline=t.a,exports.Video=t.h,exports.defaultBlockTools=t.t,exports.defaultInlineTools=t.n;
|
package/dist/tools.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { m as e } from "./chunks/constants-
|
|
2
|
-
import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-
|
|
1
|
+
import { m as e } from "./chunks/constants-BvxJIgSQ.mjs";
|
|
2
|
+
import { C as t, D as n, E as r, O as i, S as a, T as o, _ as s, a as c, b as l, c as u, d, f, g as p, h as m, i as h, k as g, l as _, m as v, n as y, o as b, p as x, r as S, s as C, t as w, u as T, v as E, w as D, x as O, y as k } from "./chunks/tools-B89FUeNz.mjs";
|
|
3
3
|
export { p as Audio, _ as Bold, T as Bookmark, D as Callout, k as Code, x as Column, v as ColumnList, f as Columns, e as Convert, t as Database, a as DatabaseRow, O as Divider, d as Embed, s as File, i as Header, E as Image, S as InlineCode, u as Italic, C as Link, n as List, b as Marker, g as Paragraph, l as Quote, h as Strikethrough, r as Table, o as Toggle, c as Underline, m as Video, w as defaultBlockTools, y as defaultInlineTools };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackuait/blok",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
|
|
5
5
|
"module": "dist/blok.mjs",
|
|
6
6
|
"main": "./dist/blok.cjs",
|
|
@@ -7,6 +7,7 @@ import { SAFE_STRUCTURAL_TAGS } from './constants';
|
|
|
7
7
|
import { preprocessGoogleDocsHtml } from './google-docs-preprocessor';
|
|
8
8
|
import { preprocessNotionHtml } from './notion-preprocessor';
|
|
9
9
|
import { NOTION_BLOCKS_V3_MIME, parseNotionBlocksV3 } from './notion-blocks-v3';
|
|
10
|
+
import { NEXT_SPACE_MIMES, parseNextSpaceBlocks } from './next-space-blocks';
|
|
10
11
|
import type { PasteHandler } from './handlers/base';
|
|
11
12
|
import { BlokDataHandler } from './handlers/blok-data-handler';
|
|
12
13
|
import { FilesHandler } from './handlers/files-handler';
|
|
@@ -114,12 +115,15 @@ export class Paste extends Module {
|
|
|
114
115
|
const plainData = dataTransfer.getData('text/plain');
|
|
115
116
|
const rawHtmlData = dataTransfer.getData('text/html');
|
|
116
117
|
|
|
117
|
-
// Native Blok clipboard data always wins. When absent, fall back to
|
|
118
|
-
//
|
|
119
|
-
// block state (checked, language, nesting, colour…) that the
|
|
120
|
-
// cannot. We translate
|
|
121
|
-
// the existing BlokDataHandler two-pass hierarchy builder.
|
|
122
|
-
const blokData =
|
|
118
|
+
// Native Blok clipboard data always wins. When absent, fall back to the
|
|
119
|
+
// lossless proprietary flavours of other editors (web→web paste): they
|
|
120
|
+
// carry full block state (checked, language, nesting, colour…) that the
|
|
121
|
+
// HTML flavour cannot. We translate them into the Blok clipboard shape so
|
|
122
|
+
// they flow through the existing BlokDataHandler two-pass hierarchy builder.
|
|
123
|
+
const blokData =
|
|
124
|
+
dataTransfer.getData(this.MIME_TYPE) ||
|
|
125
|
+
this.readNotionBlocksV3(dataTransfer) ||
|
|
126
|
+
this.readNextSpaceBlocks(dataTransfer);
|
|
123
127
|
|
|
124
128
|
// Route to handlers based on data type
|
|
125
129
|
const handled = await this.routeToHandlers(dataTransfer, plainData, rawHtmlData, blokData, pasteTarget);
|
|
@@ -153,6 +157,29 @@ export class Paste extends Module {
|
|
|
153
157
|
return JSON.stringify(parsed);
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Read buildin.ai's `text/next-space-blocks` / `text/next-space-content`
|
|
162
|
+
* clipboard flavours and translate them into the Blok clipboard JSON shape,
|
|
163
|
+
* or return '' when absent / unparseable (so the caller falls back to HTML).
|
|
164
|
+
*/
|
|
165
|
+
private readNextSpaceBlocks(dataTransfer: DataTransfer): string {
|
|
166
|
+
for (const mime of NEXT_SPACE_MIMES) {
|
|
167
|
+
const raw = dataTransfer.getData(mime);
|
|
168
|
+
|
|
169
|
+
if (!raw) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const parsed = parseNextSpaceBlocks(raw);
|
|
174
|
+
|
|
175
|
+
if (parsed !== null && parsed.length > 0) {
|
|
176
|
+
return JSON.stringify(parsed);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
|
|
156
183
|
/**
|
|
157
184
|
* Route paste data to handlers in priority order.
|
|
158
185
|
*/
|
|
@@ -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
|
+
}
|