@jackuait/blok 0.23.2 → 0.23.4

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.
Files changed (50) hide show
  1. package/README.md +3 -3
  2. package/dist/blok.cjs +1 -1
  3. package/dist/blok.iife.js +73 -73
  4. package/dist/blok.mjs +3 -3
  5. package/dist/blok.umd.js +53 -53
  6. package/dist/chunks/{blok-Cm1FH7mi.mjs → blok-BydOC-jj.mjs} +2281 -2252
  7. package/dist/chunks/{blok-9mk1DbT_.cjs → blok-qwtrt5R3.cjs} +13 -13
  8. package/dist/chunks/{constants-BTnQPoO-.mjs → constants-DZtdGLlr.mjs} +288 -284
  9. package/dist/chunks/constants-Y3Af_xZ7.cjs +606 -0
  10. package/dist/chunks/{tools-ZtOAeoEZ.cjs → tools-BmSYWm8L.cjs} +2 -2
  11. package/dist/chunks/{tools-CJIl8RiU.mjs → tools-DOZaLyYm.mjs} +420 -418
  12. package/dist/full.cjs +1 -1
  13. package/dist/full.mjs +3 -3
  14. package/dist/react.cjs +1 -1
  15. package/dist/react.mjs +68 -42
  16. package/dist/tools.cjs +1 -1
  17. package/dist/tools.mjs +2 -2
  18. package/package.json +1 -1
  19. package/src/blok.ts +12 -0
  20. package/src/components/block/tool-renderer.ts +2 -2
  21. package/src/components/blocks.ts +32 -10
  22. package/src/components/constants/test-ids.ts +17 -0
  23. package/src/components/constants.ts +5 -0
  24. package/src/components/events/BlockRendered.ts +9 -0
  25. package/src/components/events/BlocksRendered.ts +13 -0
  26. package/src/components/events/index.ts +8 -0
  27. package/src/components/inline-tools/inline-tool-link.ts +23 -9
  28. package/src/components/modules/api/events.ts +10 -10
  29. package/src/components/modules/api/index.ts +3 -0
  30. package/src/components/modules/api/tools.ts +3 -0
  31. package/src/components/modules/blockManager/blockManager.ts +1 -1
  32. package/src/components/modules/modificationsObserver.ts +40 -2
  33. package/src/components/modules/paste/index.ts +10 -1
  34. package/src/components/modules/renderer.ts +146 -131
  35. package/src/components/modules/toolbar/plus-button.ts +2 -1
  36. package/src/components/modules/toolbar/settings-toggler.ts +2 -2
  37. package/src/components/modules/tools.ts +18 -0
  38. package/src/components/tools/factory.ts +28 -1
  39. package/src/react/BlokEditor.tsx +15 -4
  40. package/src/react/config-keys.ts +3 -0
  41. package/src/react/deep-equal.ts +48 -0
  42. package/src/react/types.ts +6 -0
  43. package/src/react/useBlok.ts +60 -0
  44. package/types/api/events.d.ts +35 -5
  45. package/types/api/tools.d.ts +16 -1
  46. package/types/configs/blok-config.d.ts +54 -0
  47. package/types/events/editor-events.ts +41 -0
  48. package/types/index.d.ts +5 -1
  49. package/types/react.d.ts +2 -0
  50. package/dist/chunks/constants-CrepXIds.cjs +0 -606
@@ -42,6 +42,12 @@ export class ModificationsObserver extends Module {
42
42
  */
43
43
  private readonly batchTime = modificationsObserverBatchTimeout;
44
44
 
45
+ /**
46
+ * Set once the module is destroyed so an in-flight serialization (onSave)
47
+ * doesn't call back into a torn-down editor after its promise resolves.
48
+ */
49
+ private destroyed = false;
50
+
45
51
  /**
46
52
  * Prepare the module
47
53
  * @param options - options used by the modification observer module
@@ -103,7 +109,7 @@ export class ModificationsObserver extends Module {
103
109
  * @param event - some of our custom change events
104
110
  */
105
111
  private particularBlockChanged(event: BlockMutationEvent): void {
106
- if (this.disabled || !isFunction(this.config.onChange)) {
112
+ if (this.disabled || (!isFunction(this.config.onChange) && !isFunction(this.config.onSave))) {
107
113
  return;
108
114
  }
109
115
 
@@ -127,14 +133,45 @@ export class ModificationsObserver extends Module {
127
133
  ? queuedEvents[0]
128
134
  : queuedEvents;
129
135
 
130
- if (this.config.onChange) {
136
+ if (isFunction(this.config.onChange)) {
131
137
  this.config.onChange(this.Blok.API.methods, eventsToEmit);
132
138
  }
133
139
 
140
+ if (isFunction(this.config.onSave)) {
141
+ this.emitOnSave();
142
+ }
143
+
134
144
  this.batchingOnChangeQueue.clear();
135
145
  }, this.batchTime);
136
146
  }
137
147
 
148
+ /**
149
+ * Serializes the editor and delivers the full OutputData to the consumer's
150
+ * `onSave` callback. Invoked once per batched change window, so a burst of
151
+ * edits results in a single serialization. Skips delivery if the module was
152
+ * destroyed while the (async) serialization was in flight.
153
+ */
154
+ private emitOnSave(): void {
155
+ void this.Blok.Saver.save()
156
+ .then((data) => {
157
+ if (this.destroyed || data === undefined) {
158
+ return;
159
+ }
160
+
161
+ const { onSave } = this.config;
162
+
163
+ if (isFunction(onSave)) {
164
+ onSave(data, this.Blok.API.methods);
165
+ }
166
+ })
167
+ .catch(() => {
168
+ /**
169
+ * Serialization failed — the Saver already surfaces the error via its
170
+ * own channel, so swallow here to avoid an unhandled rejection.
171
+ */
172
+ });
173
+ }
174
+
138
175
  /**
139
176
  * Cleans up the module: disconnects the MutationObserver and cancels any
140
177
  * pending batching timeout. Called by the editor's destroy() chain so that
@@ -144,6 +181,7 @@ export class ModificationsObserver extends Module {
144
181
  */
145
182
  public destroy(): void {
146
183
  this.disabled = true;
184
+ this.destroyed = true;
147
185
  this.mutationObserver.disconnect();
148
186
 
149
187
  if (this.batchingTimeout !== null) {
@@ -113,7 +113,16 @@ export class Paste extends Module {
113
113
  */
114
114
  public async processDataTransfer(dataTransfer: DataTransfer, pasteTarget?: Element): Promise<void> {
115
115
  const plainData = dataTransfer.getData('text/plain');
116
- const rawHtmlData = dataTransfer.getData('text/html');
116
+
117
+ // Give consumers a chance to transform or drop the raw clipboard HTML
118
+ // before any Blok preprocessing/sanitization runs. Returning a string
119
+ // replaces the HTML for the rest of the pipeline; returning null aborts the
120
+ // HTML paste path entirely (an empty string makes HtmlHandler bail, so the
121
+ // paste falls through to the plain-text handler).
122
+ const clipboardHtml = dataTransfer.getData('text/html');
123
+ const rawHtmlData = clipboardHtml && this.config.onBeforePaste
124
+ ? this.config.onBeforePaste(clipboardHtml) ?? ''
125
+ : clipboardHtml;
117
126
 
118
127
  // Native Blok clipboard data always wins. When absent, fall back to the
119
128
  // lossless proprietary flavours of other editors (web→web paste): they
@@ -13,6 +13,7 @@ import {
13
13
  type DataFormatAnalysis,
14
14
  } from '../utils/data-model-transform';
15
15
  import { migrateMarkColors } from '../utils/color-migration';
16
+ import { BlocksRendered } from '../events';
16
17
 
17
18
  /**
18
19
  * Map of legacy EditorJS tool names to their Blok equivalents.
@@ -86,146 +87,160 @@ export class Renderer extends Module {
86
87
  */
87
88
  public render(blocksData: OutputBlockData[]): Promise<void> {
88
89
  return new Promise((resolve) => {
89
- const { Tools, BlockManager } = this.Blok;
90
-
91
- if (blocksData.length === 0) {
92
- BlockManager.insert();
93
- } else {
94
- // Analyze and potentially transform the input data
95
- const dataModelConfig = this.config.dataModel || 'auto';
96
- const analysis = analyzeDataFormat(blocksData);
97
- this.detectedInputFormat = analysis.format;
98
-
99
- // Transform to hierarchical if config requires it
100
- const expandedBlocks = shouldExpandToHierarchical(dataModelConfig, analysis.format)
101
- ? expandToHierarchical(blocksData)
102
- : blocksData;
103
-
104
- // Recover migrated cells whose text a pre-fix save detached to root:
105
- // re-attach `cell-<row>-<col>`-id orphans back into their empty cell.
106
- // Runs before normalize so reclaimed refs get parented in the same pass.
107
- const reclaimedBlocks = reclaimDetachedTableCells(expandedBlocks);
108
-
109
- // Tables persist child references via `data.content[r][c].blocks = [<id>]`
110
- // rather than an explicit `parent` field on each child. Pre-normalize
111
- // those parent references so downstream code that gates on parentId
112
- // (read-only cell mounter, saver filter, hierarchy queries) correctly
113
- // recognizes the children as belonging to their table.
114
- const processedBlocks = normalizeTableChildParents(reclaimedBlocks);
115
-
116
- // Note: Yjs data layer is loaded via BlockManager.insertMany() with the correct block IDs
117
-
118
- /**
119
- * Track seen IDs to detect and resolve duplicates
120
- */
121
- const seenIds = new Set<string>();
122
-
123
- /**
124
- * Create Blocks instances
125
- */
126
- const blocks = processedBlocks.map((blockData: OutputBlockData) => {
127
- const { tunes, parent, content, lastEditedAt, lastEditedBy } = blockData;
128
- const hasDuplicateId = blockData.id !== undefined && seenIds.has(blockData.id);
129
-
130
- if (hasDuplicateId) {
131
- logLabeled(`Duplicate block id «${blockData.id}» replaced with a generated id to ensure uniqueness`, 'warn');
132
- }
133
-
134
- const id = hasDuplicateId ? generateBlockId() : blockData.id;
135
-
136
- if (id !== undefined) {
137
- seenIds.add(id);
138
- }
139
- const originalTool = blockData.type;
90
+ const renderedCount = this.insertRenderedBlocks(blocksData);
140
91
 
141
- /**
142
- * Validate that block data has the expected shape.
143
- * Since OutputBlockData<Data> defaults to `any` for Data, we need to narrow the type.
144
- */
145
- const isValidBlockData = (data: unknown): data is Record<string, unknown> => {
146
- return typeof data === 'object' && data !== null;
147
- };
92
+ /**
93
+ * Wait till browser will render inserted Blocks and resolve a promise
94
+ */
95
+ window.requestIdleCallback(() => {
96
+ this.eventsDispatcher.emit(BlocksRendered, { count: renderedCount });
97
+ resolve();
98
+ }, { timeout: 2000 });
99
+ });
100
+ }
148
101
 
149
- const blockToolData = isValidBlockData(blockData.data) ? blockData.data : {};
150
-
151
- const availabilityResult = (() => {
152
- if (Tools.available.has(originalTool)) {
153
- return {
154
- tool: originalTool,
155
- data: blockToolData,
156
- };
157
- }
158
-
159
- const aliasTarget = TOOL_ALIASES[originalTool];
160
-
161
- if (aliasTarget !== undefined && Tools.available.has(aliasTarget)) {
162
- return {
163
- tool: aliasTarget,
164
- data: blockToolData,
165
- };
166
- }
167
-
168
- logLabeled(`Tool «${originalTool}» is not found. Check 'tools' property at the Blok config.`, 'warn');
169
-
170
- return {
171
- tool: Tools.stubTool,
172
- data: this.composeStubDataForTool(originalTool, blockToolData, id),
173
- };
174
- })();
175
-
176
- const buildBlock = (tool: string, data: BlockToolData): Block => {
177
- try {
178
- return BlockManager.composeBlock({
179
- id,
180
- tool,
181
- data,
182
- tunes,
183
- parentId: parent,
184
- contentIds: content,
185
- lastEditedAt,
186
- lastEditedBy,
187
- });
188
- } catch (error) {
189
- log(`Block «${tool}» skipped because of plugins error`, 'error', {
190
- data,
191
- error,
192
- });
193
-
194
- /**
195
- * If tool throws an error during render, we should render stub instead of it
196
- */
197
- const stubData = this.composeStubDataForTool(tool, data, id);
198
-
199
- return BlockManager.composeBlock({
200
- id,
201
- tool: Tools.stubTool,
202
- data: stubData,
203
- tunes,
204
- parentId: parent,
205
- contentIds: content,
206
- lastEditedAt,
207
- lastEditedBy,
208
- });
209
- }
210
- };
102
+ /**
103
+ * Inserts the given blocks (or a single default block when the input is
104
+ * empty) and returns the number of top-level blocks rendered in this batch.
105
+ * @param blocksData - blocks to render
106
+ */
107
+ private insertRenderedBlocks(blocksData: OutputBlockData[]): number {
108
+ const { Tools, BlockManager } = this.Blok;
109
+
110
+ if (blocksData.length === 0) {
111
+ BlockManager.insert();
211
112
 
212
- return buildBlock(availabilityResult.tool, availabilityResult.data);
213
- });
113
+ return 1;
114
+ }
214
115
 
215
- /**
216
- * Insert batch of Blocks
217
- */
218
- BlockManager.insertMany(blocks);
219
- migrateMarkColors(this.Blok.UI.nodes.redactor);
116
+ // Analyze and potentially transform the input data
117
+ const dataModelConfig = this.config.dataModel || 'auto';
118
+ const analysis = analyzeDataFormat(blocksData);
119
+ this.detectedInputFormat = analysis.format;
120
+
121
+ // Transform to hierarchical if config requires it
122
+ const expandedBlocks = shouldExpandToHierarchical(dataModelConfig, analysis.format)
123
+ ? expandToHierarchical(blocksData)
124
+ : blocksData;
125
+
126
+ // Recover migrated cells whose text a pre-fix save detached to root:
127
+ // re-attach `cell-<row>-<col>`-id orphans back into their empty cell.
128
+ // Runs before normalize so reclaimed refs get parented in the same pass.
129
+ const reclaimedBlocks = reclaimDetachedTableCells(expandedBlocks);
130
+
131
+ // Tables persist child references via `data.content[r][c].blocks = [<id>]`
132
+ // rather than an explicit `parent` field on each child. Pre-normalize
133
+ // those parent references so downstream code that gates on parentId
134
+ // (read-only cell mounter, saver filter, hierarchy queries) correctly
135
+ // recognizes the children as belonging to their table.
136
+ const processedBlocks = normalizeTableChildParents(reclaimedBlocks);
137
+
138
+ // Note: Yjs data layer is loaded via BlockManager.insertMany() with the correct block IDs
139
+
140
+ /**
141
+ * Track seen IDs to detect and resolve duplicates
142
+ */
143
+ const seenIds = new Set<string>();
144
+
145
+ /**
146
+ * Create Blocks instances
147
+ */
148
+ const blocks = processedBlocks.map((blockData: OutputBlockData) => {
149
+ const { tunes, parent, content, lastEditedAt, lastEditedBy } = blockData;
150
+ const hasDuplicateId = blockData.id !== undefined && seenIds.has(blockData.id);
151
+
152
+ if (hasDuplicateId) {
153
+ logLabeled(`Duplicate block id «${blockData.id}» replaced with a generated id to ensure uniqueness`, 'warn');
220
154
  }
221
155
 
156
+ const id = hasDuplicateId ? generateBlockId() : blockData.id;
157
+
158
+ if (id !== undefined) {
159
+ seenIds.add(id);
160
+ }
161
+ const originalTool = blockData.type;
162
+
222
163
  /**
223
- * Wait till browser will render inserted Blocks and resolve a promise
164
+ * Validate that block data has the expected shape.
165
+ * Since OutputBlockData<Data> defaults to `any` for Data, we need to narrow the type.
224
166
  */
225
- window.requestIdleCallback(() => {
226
- resolve();
227
- }, { timeout: 2000 });
167
+ const isValidBlockData = (data: unknown): data is Record<string, unknown> => {
168
+ return typeof data === 'object' && data !== null;
169
+ };
170
+
171
+ const blockToolData = isValidBlockData(blockData.data) ? blockData.data : {};
172
+
173
+ const availabilityResult = (() => {
174
+ if (Tools.available.has(originalTool)) {
175
+ return {
176
+ tool: originalTool,
177
+ data: blockToolData,
178
+ };
179
+ }
180
+
181
+ const aliasTarget = TOOL_ALIASES[originalTool];
182
+
183
+ if (aliasTarget !== undefined && Tools.available.has(aliasTarget)) {
184
+ return {
185
+ tool: aliasTarget,
186
+ data: blockToolData,
187
+ };
188
+ }
189
+
190
+ logLabeled(`Tool «${originalTool}» is not found. Check 'tools' property at the Blok config.`, 'warn');
191
+
192
+ return {
193
+ tool: Tools.stubTool,
194
+ data: this.composeStubDataForTool(originalTool, blockToolData, id),
195
+ };
196
+ })();
197
+
198
+ const buildBlock = (tool: string, data: BlockToolData): Block => {
199
+ try {
200
+ return BlockManager.composeBlock({
201
+ id,
202
+ tool,
203
+ data,
204
+ tunes,
205
+ parentId: parent,
206
+ contentIds: content,
207
+ lastEditedAt,
208
+ lastEditedBy,
209
+ });
210
+ } catch (error) {
211
+ log(`Block «${tool}» skipped because of plugins error`, 'error', {
212
+ data,
213
+ error,
214
+ });
215
+
216
+ /**
217
+ * If tool throws an error during render, we should render stub instead of it
218
+ */
219
+ const stubData = this.composeStubDataForTool(tool, data, id);
220
+
221
+ return BlockManager.composeBlock({
222
+ id,
223
+ tool: Tools.stubTool,
224
+ data: stubData,
225
+ tunes,
226
+ parentId: parent,
227
+ contentIds: content,
228
+ lastEditedAt,
229
+ lastEditedBy,
230
+ });
231
+ }
232
+ };
233
+
234
+ return buildBlock(availabilityResult.tool, availabilityResult.data);
228
235
  });
236
+
237
+ /**
238
+ * Insert batch of Blocks
239
+ */
240
+ BlockManager.insertMany(blocks);
241
+ migrateMarkColors(this.Blok.UI.nodes.redactor);
242
+
243
+ return blocks.length;
229
244
  }
230
245
 
231
246
  /**
@@ -1,5 +1,6 @@
1
1
  import type { BlokModules } from '../../../types-internal/blok-modules';
2
2
  import type { Block } from '../../block';
3
+ import { DATA_ATTR, TEST_ID } from '../../constants';
3
4
  import { Dom as $ } from '../../dom';
4
5
  import { IconPlus } from '../../icons';
5
6
  import { SelectionUtils } from '../../selection/index';
@@ -126,7 +127,7 @@ export class PlusButtonHandler {
126
127
  innerHTML: IconPlus,
127
128
  });
128
129
 
129
- plusButton.setAttribute('data-blok-testid', 'plus-button');
130
+ plusButton.setAttribute(DATA_ATTR.testid, TEST_ID.plusButton);
130
131
 
131
132
  // eslint-disable-next-line no-param-reassign -- nodes is mutated by design
132
133
  nodes.plusButton = plusButton;
@@ -1,6 +1,6 @@
1
1
  import type { BlokModules } from '../../../types-internal/blok-modules';
2
2
  import type { Block } from '../../block';
3
- import { DATA_ATTR } from '../../constants';
3
+ import { DATA_ATTR, TEST_ID } from '../../constants';
4
4
  import { Dom as $ } from '../../dom';
5
5
  import { IconMenu } from '../../icons';
6
6
  import { getUserOS } from '../../utils';
@@ -136,7 +136,7 @@ export class SettingsTogglerHandler {
136
136
 
137
137
  settingsToggler.setAttribute(DATA_ATTR.settingsToggler, '');
138
138
  settingsToggler.setAttribute(DATA_ATTR.dragHandle, '');
139
- settingsToggler.setAttribute('data-blok-testid', 'settings-toggler');
139
+ settingsToggler.setAttribute(DATA_ATTR.testid, TEST_ID.settingsToggler);
140
140
 
141
141
  // Accessibility: make the drag handle accessible to screen readers
142
142
  // Using tabindex="-1" keeps it accessible but removes from tab order
@@ -212,6 +212,24 @@ export class Tools extends Module {
212
212
  this.prepareBlockTools();
213
213
  }
214
214
 
215
+ /**
216
+ * Shallow-merges new user configuration into an already-prepared tool.
217
+ *
218
+ * Lets consumers swap config (e.g. an uploader) at runtime without recreating
219
+ * the editor. The stored config is mutated in place, so the live tool adapter
220
+ * and any block created afterwards pick up the new values. Blocks already
221
+ * mounted keep their current config until they are re-rendered.
222
+ * @param name - tool name
223
+ * @param config - partial tool config to merge in
224
+ */
225
+ public updateToolConfig(name: string, config: Partial<ToolConfig>): void {
226
+ if (!this.available.has(name) && !this.unavailable.has(name)) {
227
+ throw new Error(`Tool "${name}" is not registered.`);
228
+ }
229
+
230
+ this.getFactory().updateConfig(name, config);
231
+ }
232
+
215
233
  /**
216
234
  * Return general Sanitizer config for all inline tools
217
235
  */
@@ -1,6 +1,6 @@
1
1
  import type { API as ApiMethods, I18n } from '../../../types';
2
2
  import type { BlokConfig } from '../../../types/configs';
3
- import type { ToolConstructable, ToolSettings } from '../../../types/tools';
3
+ import type { ToolConfig, ToolConstructable, ToolSettings } from '../../../types/tools';
4
4
  import type { API as ApiModule } from '../modules/api';
5
5
 
6
6
  import { InternalInlineToolSettings, InternalTuneSettings } from './base';
@@ -45,6 +45,33 @@ export class ToolsFactory {
45
45
  this.blokConfig = blokConfig;
46
46
  }
47
47
 
48
+ /**
49
+ * Shallow-merges new user configuration into a registered tool's stored config.
50
+ *
51
+ * The merge targets the tool's nested `config` object (the part passed to the
52
+ * tool constructor) and mutates it IN PLACE. Adapters read their config lazily
53
+ * and share this exact object reference, so the live adapter — and any freshly
54
+ * built adapter — both observe the new values immediately.
55
+ * @param name - tool name
56
+ * @param config - partial tool config to merge in
57
+ */
58
+ public updateConfig(name: string, config: Partial<ToolConfig>): void {
59
+ const settings = this.config[name];
60
+
61
+ if (settings === undefined) {
62
+ throw new Error(`Tool "${name}" is not registered.`);
63
+ }
64
+
65
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal: mutating legacy nested config in place to keep live + future adapters in sync
66
+ if (settings.config === undefined) {
67
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal: initialize nested config object before merging
68
+ settings.config = {};
69
+ }
70
+
71
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal: in-place merge so the shared nested config reference reflects the update
72
+ Object.assign(settings.config, config);
73
+ }
74
+
48
75
  /**
49
76
  * Returns Tool object based on it's type
50
77
  * @param name - tool name
@@ -20,10 +20,21 @@ import type { UseBlokConfig } from './types';
20
20
  * keep their editor-config meaning (they collide with div attributes of the same
21
21
  * name), so the container is styled via `className` rather than an inline `style`.
22
22
  *
23
- * `data` is uncontrolled (seed-only): it sets the INITIAL content. After mount the
24
- * editor owns the document passing a new `data` reference does NOT reload it.
25
- * Read content via `onChange` or the ref (`ref.current.save()`); replace it
26
- * imperatively via `ref.current.render(newData)`.
23
+ * `data` is reactive (controlled-ish): it seeds the INITIAL content and, after
24
+ * mount, changing it to new *content* re-renders the editor via `render()` no
25
+ * recreation. Updates are deep-equal–deduped (a new reference with identical
26
+ * content is a no-op, so it won't clobber the caret) and serialized (rapid
27
+ * changes can't overlap). For ad-hoc reloads you can still call
28
+ * `ref.current.render(newData)`.
29
+ *
30
+ * Pair `data` with `onSave` for a true controlled component: `onSave` is the
31
+ * output half, firing (debounced) with the full serialized `OutputData` on every
32
+ * content change — no manual `ref.current.save()` polling. Echoing that payload
33
+ * straight back via `onSave={setData}` is safe and caret-stable: the adapter
34
+ * records the editor's own emitted output as the content baseline, so the
35
+ * `onSave → setState → data` round-trip deep-equals that baseline and is deduped
36
+ * to a no-op (no re-render, no caret reset, no recursion). Genuine external `data`
37
+ * changes still re-render in place.
27
38
  */
28
39
  export interface BlokEditorProps
29
40
  extends Omit<UseBlokConfig, 'onReady'>,
@@ -25,9 +25,12 @@ export const USE_BLOK_CONFIG_KEYS = [
25
25
  'logLevel',
26
26
  'readOnly',
27
27
  'i18n',
28
+ 'link',
28
29
  'linkPaste',
30
+ 'onBeforePaste',
29
31
  'onReady',
30
32
  'onChange',
33
+ 'onSave',
31
34
  'inlineToolbar',
32
35
  'tunes',
33
36
  'style',
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Structural deep equality for JSON-like values (Blok `OutputData`).
3
+ *
4
+ * Used to dedupe reactive `data` updates so identical content does not trigger
5
+ * a redundant re-render that would clobber the caret/selection. Handles the
6
+ * value shapes that appear in editor data: plain objects, arrays, and
7
+ * primitives (including `null`/`undefined`).
8
+ * @param a - first value to compare
9
+ * @param b - second value to compare
10
+ * @returns true when both values are structurally equal
11
+ */
12
+ export function deepEqual(a: unknown, b: unknown): boolean {
13
+ if (a === b) {
14
+ return true;
15
+ }
16
+
17
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
18
+ return false;
19
+ }
20
+
21
+ const aIsArray = Array.isArray(a);
22
+ const bIsArray = Array.isArray(b);
23
+
24
+ if (aIsArray !== bIsArray) {
25
+ return false;
26
+ }
27
+
28
+ if (aIsArray && bIsArray) {
29
+ if (a.length !== b.length) {
30
+ return false;
31
+ }
32
+
33
+ return a.every((item, index) => deepEqual(item, b[index]));
34
+ }
35
+
36
+ const aObj = a as Record<string, unknown>;
37
+ const bObj = b as Record<string, unknown>;
38
+ const aKeys = Object.keys(aObj);
39
+ const bKeys = Object.keys(bObj);
40
+
41
+ if (aKeys.length !== bKeys.length) {
42
+ return false;
43
+ }
44
+
45
+ return aKeys.every(
46
+ (key) => Object.prototype.hasOwnProperty.call(bObj, key) && deepEqual(aObj[key], bObj[key])
47
+ );
48
+ }
@@ -11,6 +11,12 @@ import type { BlokConfig, Blok, EditorWidth } from '@/types';
11
11
  * - `theme` — calls `editor.theme.set(value)`
12
12
  * - `width` — calls `editor.width.set(value)`
13
13
  * - `placeholder` — calls `editor.placeholder.set(value)`
14
+ * - `data` — re-renders via `editor.render(value)` when content changes
15
+ * (deep-equal–deduped and serialized; seeds the initial content at creation)
16
+ *
17
+ * The `onSave` config (inherited from `BlokConfig`) is the controlled output
18
+ * half: it fires with the full serialized `OutputData` on every content change,
19
+ * so `data` + `onSave` form a controlled component without manual `save()` calls.
14
20
  */
15
21
  export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
16
22
  /** Editor content width mode. Synced reactively after mount via `editor.width.set()`. */