@reiwuzen/blocky 1.2.0 → 1.3.1

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/index.cjs CHANGED
@@ -21,14 +21,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  applyMarkdownTransform: () => applyMarkdownTransform,
24
- blockDeleteLastChar: () => blockDeleteLastChar,
25
- blockDeleteRange: () => blockDeleteRange,
26
- blockInsertAt: () => blockInsertAt,
27
- blockReplaceRange: () => blockReplaceRange,
28
24
  canRedo: () => canRedo,
29
25
  canUndo: () => canUndo,
30
26
  changeBlockType: () => changeBlockType,
31
27
  createBlock: () => createBlock,
28
+ createBlockAfter: () => createBlockAfter,
32
29
  createHistory: () => createHistory,
33
30
  currentBlocks: () => currentBlocks,
34
31
  deleteBlock: () => deleteBlock,
@@ -37,8 +34,7 @@ __export(index_exports, {
37
34
  deserialize: () => deserialize,
38
35
  deserializeNodes: () => deserializeNodes,
39
36
  duplicateBlock: () => duplicateBlock,
40
- flatToPosition: () => flatToPosition,
41
- flatToSelection: () => flatToSelection,
37
+ duplicateBlockAfter: () => duplicateBlockAfter,
42
38
  formatNodes: () => formatNodes,
43
39
  generateId: () => generateId,
44
40
  indentBlock: () => indentBlock,
@@ -48,7 +44,6 @@ __export(index_exports, {
48
44
  mergeBlocks: () => mergeBlocks,
49
45
  moveBlock: () => moveBlock,
50
46
  outdentBlock: () => outdentBlock,
51
- positionToFlat: () => positionToFlat,
52
47
  push: () => push,
53
48
  redo: () => redo,
54
49
  removeLink: () => removeLink,
@@ -70,7 +65,7 @@ __export(index_exports, {
70
65
  });
71
66
  module.exports = __toCommonJS(index_exports);
72
67
 
73
- // src/utils/block.ts
68
+ // src/engine/block.ts
74
69
  var import_result = require("@reiwuzen/result");
75
70
  var import_uuid = require("uuid");
76
71
  function generateId(fn) {
@@ -109,12 +104,28 @@ function createBlock(type, idFn) {
109
104
  return block;
110
105
  });
111
106
  }
112
- function insertBlockAfter(blocks, afterId, type, idFn) {
113
- return createBlock(type, idFn).map((newBlock) => {
107
+ function createBlockAfter(blocks, afterId, type, idFn) {
108
+ return createBlock(type, idFn).andThen((newBlock) => {
114
109
  const index = blocks.findIndex((b) => b.id === afterId);
110
+ if (index === -1) return import_result.Result.Err(`[BlockNotFound]: ${afterId}`);
115
111
  const next = [...blocks];
116
112
  next.splice(index + 1, 0, newBlock);
117
- return { blocks: next, newId: newBlock.id };
113
+ return import_result.Result.Ok({ blocks: next, newId: newBlock.id });
114
+ });
115
+ }
116
+ function insertBlockAfter(blocks, afterId, insertBlock) {
117
+ const targetBlockIndex = blocks.findIndex((b) => b.id === afterId);
118
+ if (targetBlockIndex === -1) {
119
+ return import_result.Result.Err(`No block found with id: ${afterId}`);
120
+ }
121
+ const newBlocks = [
122
+ ...blocks.slice(0, targetBlockIndex + 1),
123
+ insertBlock,
124
+ ...blocks.slice(targetBlockIndex + 1)
125
+ ];
126
+ return import_result.Result.Ok({
127
+ blocks: newBlocks,
128
+ newFocusId: insertBlock.id
118
129
  });
119
130
  }
120
131
  function deleteBlock(blocks, id) {
@@ -122,8 +133,19 @@ function deleteBlock(blocks, id) {
122
133
  const prevId = blocks[index - 1]?.id ?? blocks[index + 1]?.id ?? "";
123
134
  return { blocks: blocks.filter((b) => b.id !== id), prevId };
124
135
  }
125
- function duplicateBlock(incoming, newId) {
126
- return { ...incoming, id: newId };
136
+ function duplicateBlock(incoming, newId = crypto.randomUUID()) {
137
+ return {
138
+ ...incoming,
139
+ id: newId,
140
+ meta: { ...incoming.meta },
141
+ content: incoming.content.map((node) => ({ ...node }))
142
+ };
143
+ }
144
+ function duplicateBlockAfter(blocks, id, newId) {
145
+ const targetBlock = blocks.find((b) => b.id === id);
146
+ if (targetBlock == null) return import_result.Result.Err(`[BlockNotFound]: ${id}`);
147
+ const dup = duplicateBlock(targetBlock, newId);
148
+ return insertBlockAfter(blocks, id, dup);
127
149
  }
128
150
  function moveBlock(blocks, id, direction) {
129
151
  const index = blocks.findIndex((b) => b.id === id);
@@ -439,25 +461,56 @@ function splitBlock(block, nodeIndex, offset) {
439
461
  const targetLen = target ? getTextLength(target) : 0;
440
462
  if (offset < 0 || offset > targetLen)
441
463
  return import_result3.Result.Err(`offset (${offset}) out of bounds`);
464
+ const atStart = nodeIndex === 0 && offset === 0;
465
+ const atEnd = nodeIndex === nodes.length - 1 && offset === targetLen;
442
466
  const beforeNodes = [
443
467
  ...nodes.slice(0, nodeIndex),
444
- ...target && offset > 0 ? [sliceNode(target, 0, offset)].filter(Boolean) : []
468
+ ...target && offset > 0 ? [sliceNode(target, 0, offset)].filter(isNode) : []
445
469
  ];
446
470
  const afterNodes = [
447
- ...target && offset < targetLen ? [sliceNode(target, offset, targetLen)].filter(Boolean) : [],
471
+ ...target && offset < targetLen ? [sliceNode(target, offset, targetLen)].filter(isNode) : [],
448
472
  ...nodes.slice(nodeIndex + 1)
449
473
  ];
450
- const original = {
474
+ if (atStart) {
475
+ const left2 = {
476
+ ...block,
477
+ content: []
478
+ };
479
+ const right2 = {
480
+ id: generateId(),
481
+ type: block.type,
482
+ content: nodes,
483
+ meta: {}
484
+ };
485
+ return import_result3.Result.Ok([left2, right2]);
486
+ }
487
+ if (atEnd) {
488
+ const left2 = {
489
+ ...block,
490
+ content: nodes
491
+ };
492
+ const right2 = {
493
+ id: generateId(),
494
+ type: "paragraph",
495
+ content: [],
496
+ meta: {}
497
+ };
498
+ return import_result3.Result.Ok([left2, right2]);
499
+ }
500
+ const left = {
451
501
  ...block,
452
502
  content: beforeNodes
453
503
  };
454
- const newBlock = {
504
+ const right = {
455
505
  id: generateId(),
456
506
  type: "paragraph",
457
507
  content: afterNodes,
458
508
  meta: {}
459
509
  };
460
- return import_result3.Result.Ok([original, newBlock]);
510
+ return import_result3.Result.Ok([left, right]);
511
+ }
512
+ function isNode(node) {
513
+ return node !== null;
461
514
  }
462
515
  function mergeBlocks(blockA, blockB) {
463
516
  if (blockA.type === "code" || blockA.type === "equation")
@@ -511,18 +564,6 @@ function stripPrefix(block, prefix) {
511
564
  if (stripped.length === 0) return [];
512
565
  return [{ ...first, text: stripped }, ...block.content.slice(1)];
513
566
  }
514
- function buildMeta(type) {
515
- switch (type) {
516
- case "bullet":
517
- case "number":
518
- case "todo":
519
- return { depth: 0 };
520
- case "heading1":
521
- case "heading2":
522
- case "heading3":
523
- return {};
524
- }
525
- }
526
567
  function applyMarkdownTransform(block, cursorOffset) {
527
568
  if (block.type !== "paragraph")
528
569
  return import_result4.Result.Ok({ block, converted: false });
@@ -539,7 +580,7 @@ function applyMarkdownTransform(block, cursorOffset) {
539
580
  id: block.id,
540
581
  type: targetType,
541
582
  content: strippedContent,
542
- meta: buildMeta(targetType)
583
+ meta: buildMetaForTarget(targetType)
543
584
  };
544
585
  return import_result4.Result.Ok({ block: converted, converted: true });
545
586
  }
@@ -766,67 +807,8 @@ ${block.content[0].text}
766
807
  }).join("\n\n");
767
808
  }
768
809
 
769
- // src/engine/cursor.ts
770
- var import_result6 = require("@reiwuzen/result");
771
- function getTextLength2(node) {
772
- if (node.type === "text" || node.type === "code") return node.text.length;
773
- if (node.type === "equation") return node.latex.length;
774
- return 0;
775
- }
776
- function flatToPosition(block, flatOffset) {
777
- if (block.type === "code" || block.type === "equation") {
778
- const node = block.content[0];
779
- const len = getTextLength2(node);
780
- if (flatOffset < 0 || flatOffset > len)
781
- return import_result6.Result.Err(`flatOffset (${flatOffset}) out of bounds (length=${len})`);
782
- return import_result6.Result.Ok({ nodeIndex: 0, offset: flatOffset });
783
- }
784
- const nodes = block.content;
785
- if (flatOffset < 0)
786
- return import_result6.Result.Err(`flatOffset (${flatOffset}) cannot be negative`);
787
- let accumulated = 0;
788
- for (let i = 0; i < nodes.length; i++) {
789
- const len = getTextLength2(nodes[i]);
790
- if (flatOffset <= accumulated + len) {
791
- return import_result6.Result.Ok({ nodeIndex: i, offset: flatOffset - accumulated });
792
- }
793
- accumulated += len;
794
- }
795
- if (flatOffset === accumulated) {
796
- const last = nodes.length - 1;
797
- return import_result6.Result.Ok({ nodeIndex: Math.max(0, last), offset: getTextLength2(nodes[last]) });
798
- }
799
- return import_result6.Result.Err(
800
- `flatOffset (${flatOffset}) out of bounds (total length=${accumulated})`
801
- );
802
- }
803
- function flatToSelection(block, start, end) {
804
- if (start > end)
805
- return import_result6.Result.Err(`start (${start}) cannot be greater than end (${end})`);
806
- return flatToPosition(block, start).andThen(
807
- (startPos) => flatToPosition(block, end).map((endPos) => ({
808
- startIndex: startPos.nodeIndex,
809
- startOffset: startPos.offset,
810
- endIndex: endPos.nodeIndex,
811
- endOffset: endPos.offset
812
- }))
813
- );
814
- }
815
- function positionToFlat(block, nodeIndex, offset) {
816
- if (block.type === "code" || block.type === "equation")
817
- return import_result6.Result.Ok(offset);
818
- const nodes = block.content;
819
- if (nodeIndex < 0 || nodeIndex >= nodes.length)
820
- return import_result6.Result.Err(`nodeIndex (${nodeIndex}) out of bounds`);
821
- let flat = 0;
822
- for (let i = 0; i < nodeIndex; i++) {
823
- flat += getTextLength2(nodes[i]);
824
- }
825
- return import_result6.Result.Ok(flat + offset);
826
- }
827
-
828
810
  // src/engine/history.ts
829
- var import_result7 = require("@reiwuzen/result");
811
+ var import_result6 = require("@reiwuzen/result");
830
812
  function createHistory(initialBlocks) {
831
813
  return {
832
814
  past: [],
@@ -844,9 +826,9 @@ function push(history, blocks, maxSize = 100) {
844
826
  }
845
827
  function undo(history) {
846
828
  if (history.past.length === 0)
847
- return import_result7.Result.Err("Nothing to undo");
829
+ return import_result6.Result.Err("Nothing to undo");
848
830
  const previous = history.past[history.past.length - 1];
849
- return import_result7.Result.Ok({
831
+ return import_result6.Result.Ok({
850
832
  past: history.past.slice(0, -1),
851
833
  present: previous,
852
834
  future: [history.present, ...history.future]
@@ -854,9 +836,9 @@ function undo(history) {
854
836
  }
855
837
  function redo(history) {
856
838
  if (history.future.length === 0)
857
- return import_result7.Result.Err("Nothing to redo");
839
+ return import_result6.Result.Err("Nothing to redo");
858
840
  const next = history.future[0];
859
- return import_result7.Result.Ok({
841
+ return import_result6.Result.Ok({
860
842
  past: [...history.past, history.present],
861
843
  present: next,
862
844
  future: history.future.slice(1)
@@ -865,54 +847,14 @@ function redo(history) {
865
847
  var canUndo = (history) => history.past.length > 0;
866
848
  var canRedo = (history) => history.future.length > 0;
867
849
  var currentBlocks = (history) => history.present.blocks;
868
-
869
- // src/engine/block.ts
870
- function blockInsertAt(block, nodeIndex, offset, incoming) {
871
- return insertAt(block, nodeIndex, offset, incoming).map(
872
- (content) => ({
873
- ...block,
874
- content
875
- })
876
- );
877
- }
878
- function blockDeleteLastChar(block) {
879
- return deleteLastChar(block).map(
880
- (content) => ({
881
- ...block,
882
- content
883
- })
884
- );
885
- }
886
- function blockDeleteRange(block, startNodeIndex, startOffset, endNodeIndex, endOffset) {
887
- return deleteRange(
888
- block,
889
- startNodeIndex,
890
- startOffset,
891
- endNodeIndex,
892
- endOffset
893
- ).map((content) => ({ ...block, content }));
894
- }
895
- function blockReplaceRange(block, startNodeIndex, startOffset, endNodeIndex, endOffset, incoming) {
896
- return replaceRange(
897
- block,
898
- startNodeIndex,
899
- startOffset,
900
- endNodeIndex,
901
- endOffset,
902
- incoming
903
- ).map((content) => ({ ...block, content }));
904
- }
905
850
  // Annotate the CommonJS export names for ESM import in node:
906
851
  0 && (module.exports = {
907
852
  applyMarkdownTransform,
908
- blockDeleteLastChar,
909
- blockDeleteRange,
910
- blockInsertAt,
911
- blockReplaceRange,
912
853
  canRedo,
913
854
  canUndo,
914
855
  changeBlockType,
915
856
  createBlock,
857
+ createBlockAfter,
916
858
  createHistory,
917
859
  currentBlocks,
918
860
  deleteBlock,
@@ -921,8 +863,7 @@ function blockReplaceRange(block, startNodeIndex, startOffset, endNodeIndex, end
921
863
  deserialize,
922
864
  deserializeNodes,
923
865
  duplicateBlock,
924
- flatToPosition,
925
- flatToSelection,
866
+ duplicateBlockAfter,
926
867
  formatNodes,
927
868
  generateId,
928
869
  indentBlock,
@@ -932,7 +873,6 @@ function blockReplaceRange(block, startNodeIndex, startOffset, endNodeIndex, end
932
873
  mergeBlocks,
933
874
  moveBlock,
934
875
  outdentBlock,
935
- positionToFlat,
936
876
  push,
937
877
  redo,
938
878
  removeLink,
package/dist/index.d.cts CHANGED
@@ -197,41 +197,6 @@ declare function toPlainText(nodes: Node[]): string;
197
197
  */
198
198
  declare function toMarkdown(blocks: AnyBlock[]): string;
199
199
 
200
- type CursorPosition = {
201
- nodeIndex: number;
202
- offset: number;
203
- };
204
- /**
205
- * Convert a flat UI cursor offset to { nodeIndex, offset }.
206
- *
207
- * The browser gives a single number representing position in the
208
- * concatenated text of the block. This function walks the node array
209
- * and finds which node that position falls in and where within it.
210
- *
211
- * e.g. nodes = ["Hello"(5), " World"(6)]
212
- * flatOffset=0 → { nodeIndex: 0, offset: 0 }
213
- * flatOffset=5 → { nodeIndex: 0, offset: 5 } (end of first node)
214
- * flatOffset=6 → { nodeIndex: 1, offset: 1 }
215
- * flatOffset=11 → { nodeIndex: 1, offset: 6 } (end of last node)
216
- */
217
- declare function flatToPosition(block: AnyBlock, flatOffset: number): Result<CursorPosition, string>;
218
- /**
219
- * Convert a flat UI selection { start, end } to NodeSelection.
220
- *
221
- * The browser gives two flat offsets from window.getSelection().
222
- * This calls flatToPosition twice and returns a NodeSelection
223
- * ready to pass to toggleBold, formatNodes, deleteRange, etc.
224
- *
225
- * e.g. nodes = ["Hello"(bold), " World"]
226
- * { start: 3, end: 8 } → { startIndex:0, startOffset:3, endIndex:1, endOffset:3 }
227
- */
228
- declare function flatToSelection(block: AnyBlock, start: number, end: number): Result<NodeSelection, string>;
229
- /**
230
- * Inverse — convert { nodeIndex, offset } back to a flat offset.
231
- * Useful after engine operations to restore cursor position in the DOM.
232
- */
233
- declare function positionToFlat(block: AnyBlock, nodeIndex: number, offset: number): Result<number, string>;
234
-
235
200
  type HistoryEntry = {
236
201
  blocks: AnyBlock[];
237
202
  timestamp: number;
@@ -278,15 +243,23 @@ declare function generateId(fn?: () => string): string;
278
243
  * @param idFn - optional custom id generator (defaults to uuid v7)
279
244
  */
280
245
  declare function createBlock<T extends BlockType>(type: T, idFn?: () => string): Result<Block<T>, unknown>;
281
- declare function insertBlockAfter<T extends BlockType>(blocks: AnyBlock[], afterId: string, type: T, idFn?: () => string): Result<{
246
+ declare function createBlockAfter<T extends BlockType>(blocks: AnyBlock[], afterId: string, type: T, idFn?: () => string): Result<{
282
247
  blocks: AnyBlock[];
283
248
  newId: string;
284
249
  }, unknown>;
250
+ declare function insertBlockAfter(blocks: AnyBlock[], afterId: string, insertBlock: AnyBlock): Result<{
251
+ blocks: AnyBlock[];
252
+ newFocusId: string;
253
+ }, unknown>;
285
254
  declare function deleteBlock(blocks: AnyBlock[], id: string): {
286
255
  blocks: AnyBlock[];
287
256
  prevId: string;
288
257
  };
289
- declare function duplicateBlock(incoming: AnyBlock, newId: string): AnyBlock;
258
+ declare function duplicateBlock(incoming: AnyBlock, newId?: string): AnyBlock;
259
+ declare function duplicateBlockAfter(blocks: AnyBlock[], id: string, newId?: string): Result<{
260
+ blocks: AnyBlock[];
261
+ newFocusId: string;
262
+ }, unknown>;
290
263
  /**
291
264
  * Move a block up or down by one position in the array.
292
265
  * Returns the same array unchanged if block is already at the boundary.
@@ -294,13 +267,4 @@ declare function duplicateBlock(incoming: AnyBlock, newId: string): AnyBlock;
294
267
  */
295
268
  declare function moveBlock(blocks: AnyBlock[], id: string, direction: "up" | "down"): Result<AnyBlock[], string>;
296
269
 
297
- /**─── Wrappers ──────────────────────────────────────────────────────────────────
298
- * Each function runs the content engine operation and returns Result<AnyBlock>
299
- instead of Result<BlockContent<T>> — caller gets the full updated block back.
300
- */
301
- declare function blockInsertAt(block: AnyBlock, nodeIndex: number, offset: number, incoming: Node): Result<AnyBlock>;
302
- declare function blockDeleteLastChar(block: AnyBlock): Result<AnyBlock>;
303
- declare function blockDeleteRange(block: AnyBlock, startNodeIndex: number, startOffset: number, endNodeIndex: number, endOffset: number): Result<AnyBlock>;
304
- declare function blockReplaceRange(block: AnyBlock, startNodeIndex: number, startOffset: number, endNodeIndex: number, endOffset: number, incoming: Node): Result<AnyBlock>;
305
-
306
- export { type AnyBlock, type Block, type BlockContent, type BlockMeta, type BlockType, type History, type HistoryEntry, type Node, type NodeSelection, applyMarkdownTransform, blockDeleteLastChar, blockDeleteRange, blockInsertAt, blockReplaceRange, canRedo, canUndo, changeBlockType, createBlock, createHistory, currentBlocks, deleteBlock, deleteLastChar, deleteRange, deserialize, deserializeNodes, duplicateBlock, flatToPosition, flatToSelection, formatNodes, generateId, indentBlock, insertAt, insertBlockAfter, mergeAdjacentNodes, mergeBlocks, moveBlock, outdentBlock, positionToFlat, push, redo, removeLink, replaceRange, serialize, serializeNodes, setLink, splitBlock, toMarkdown, toPlainText, toggleBold, toggleColor, toggleHighlight, toggleItalic, toggleStrikethrough, toggleTodo, toggleUnderline, undo };
270
+ export { type AnyBlock, type Block, type BlockContent, type BlockMeta, type BlockType, type History, type HistoryEntry, type Node, type NodeSelection, applyMarkdownTransform, canRedo, canUndo, changeBlockType, createBlock, createBlockAfter, createHistory, currentBlocks, deleteBlock, deleteLastChar, deleteRange, deserialize, deserializeNodes, duplicateBlock, duplicateBlockAfter, formatNodes, generateId, indentBlock, insertAt, insertBlockAfter, mergeAdjacentNodes, mergeBlocks, moveBlock, outdentBlock, push, redo, removeLink, replaceRange, serialize, serializeNodes, setLink, splitBlock, toMarkdown, toPlainText, toggleBold, toggleColor, toggleHighlight, toggleItalic, toggleStrikethrough, toggleTodo, toggleUnderline, undo };
package/dist/index.d.ts CHANGED
@@ -197,41 +197,6 @@ declare function toPlainText(nodes: Node[]): string;
197
197
  */
198
198
  declare function toMarkdown(blocks: AnyBlock[]): string;
199
199
 
200
- type CursorPosition = {
201
- nodeIndex: number;
202
- offset: number;
203
- };
204
- /**
205
- * Convert a flat UI cursor offset to { nodeIndex, offset }.
206
- *
207
- * The browser gives a single number representing position in the
208
- * concatenated text of the block. This function walks the node array
209
- * and finds which node that position falls in and where within it.
210
- *
211
- * e.g. nodes = ["Hello"(5), " World"(6)]
212
- * flatOffset=0 → { nodeIndex: 0, offset: 0 }
213
- * flatOffset=5 → { nodeIndex: 0, offset: 5 } (end of first node)
214
- * flatOffset=6 → { nodeIndex: 1, offset: 1 }
215
- * flatOffset=11 → { nodeIndex: 1, offset: 6 } (end of last node)
216
- */
217
- declare function flatToPosition(block: AnyBlock, flatOffset: number): Result<CursorPosition, string>;
218
- /**
219
- * Convert a flat UI selection { start, end } to NodeSelection.
220
- *
221
- * The browser gives two flat offsets from window.getSelection().
222
- * This calls flatToPosition twice and returns a NodeSelection
223
- * ready to pass to toggleBold, formatNodes, deleteRange, etc.
224
- *
225
- * e.g. nodes = ["Hello"(bold), " World"]
226
- * { start: 3, end: 8 } → { startIndex:0, startOffset:3, endIndex:1, endOffset:3 }
227
- */
228
- declare function flatToSelection(block: AnyBlock, start: number, end: number): Result<NodeSelection, string>;
229
- /**
230
- * Inverse — convert { nodeIndex, offset } back to a flat offset.
231
- * Useful after engine operations to restore cursor position in the DOM.
232
- */
233
- declare function positionToFlat(block: AnyBlock, nodeIndex: number, offset: number): Result<number, string>;
234
-
235
200
  type HistoryEntry = {
236
201
  blocks: AnyBlock[];
237
202
  timestamp: number;
@@ -278,15 +243,23 @@ declare function generateId(fn?: () => string): string;
278
243
  * @param idFn - optional custom id generator (defaults to uuid v7)
279
244
  */
280
245
  declare function createBlock<T extends BlockType>(type: T, idFn?: () => string): Result<Block<T>, unknown>;
281
- declare function insertBlockAfter<T extends BlockType>(blocks: AnyBlock[], afterId: string, type: T, idFn?: () => string): Result<{
246
+ declare function createBlockAfter<T extends BlockType>(blocks: AnyBlock[], afterId: string, type: T, idFn?: () => string): Result<{
282
247
  blocks: AnyBlock[];
283
248
  newId: string;
284
249
  }, unknown>;
250
+ declare function insertBlockAfter(blocks: AnyBlock[], afterId: string, insertBlock: AnyBlock): Result<{
251
+ blocks: AnyBlock[];
252
+ newFocusId: string;
253
+ }, unknown>;
285
254
  declare function deleteBlock(blocks: AnyBlock[], id: string): {
286
255
  blocks: AnyBlock[];
287
256
  prevId: string;
288
257
  };
289
- declare function duplicateBlock(incoming: AnyBlock, newId: string): AnyBlock;
258
+ declare function duplicateBlock(incoming: AnyBlock, newId?: string): AnyBlock;
259
+ declare function duplicateBlockAfter(blocks: AnyBlock[], id: string, newId?: string): Result<{
260
+ blocks: AnyBlock[];
261
+ newFocusId: string;
262
+ }, unknown>;
290
263
  /**
291
264
  * Move a block up or down by one position in the array.
292
265
  * Returns the same array unchanged if block is already at the boundary.
@@ -294,13 +267,4 @@ declare function duplicateBlock(incoming: AnyBlock, newId: string): AnyBlock;
294
267
  */
295
268
  declare function moveBlock(blocks: AnyBlock[], id: string, direction: "up" | "down"): Result<AnyBlock[], string>;
296
269
 
297
- /**─── Wrappers ──────────────────────────────────────────────────────────────────
298
- * Each function runs the content engine operation and returns Result<AnyBlock>
299
- instead of Result<BlockContent<T>> — caller gets the full updated block back.
300
- */
301
- declare function blockInsertAt(block: AnyBlock, nodeIndex: number, offset: number, incoming: Node): Result<AnyBlock>;
302
- declare function blockDeleteLastChar(block: AnyBlock): Result<AnyBlock>;
303
- declare function blockDeleteRange(block: AnyBlock, startNodeIndex: number, startOffset: number, endNodeIndex: number, endOffset: number): Result<AnyBlock>;
304
- declare function blockReplaceRange(block: AnyBlock, startNodeIndex: number, startOffset: number, endNodeIndex: number, endOffset: number, incoming: Node): Result<AnyBlock>;
305
-
306
- export { type AnyBlock, type Block, type BlockContent, type BlockMeta, type BlockType, type History, type HistoryEntry, type Node, type NodeSelection, applyMarkdownTransform, blockDeleteLastChar, blockDeleteRange, blockInsertAt, blockReplaceRange, canRedo, canUndo, changeBlockType, createBlock, createHistory, currentBlocks, deleteBlock, deleteLastChar, deleteRange, deserialize, deserializeNodes, duplicateBlock, flatToPosition, flatToSelection, formatNodes, generateId, indentBlock, insertAt, insertBlockAfter, mergeAdjacentNodes, mergeBlocks, moveBlock, outdentBlock, positionToFlat, push, redo, removeLink, replaceRange, serialize, serializeNodes, setLink, splitBlock, toMarkdown, toPlainText, toggleBold, toggleColor, toggleHighlight, toggleItalic, toggleStrikethrough, toggleTodo, toggleUnderline, undo };
270
+ export { type AnyBlock, type Block, type BlockContent, type BlockMeta, type BlockType, type History, type HistoryEntry, type Node, type NodeSelection, applyMarkdownTransform, canRedo, canUndo, changeBlockType, createBlock, createBlockAfter, createHistory, currentBlocks, deleteBlock, deleteLastChar, deleteRange, deserialize, deserializeNodes, duplicateBlock, duplicateBlockAfter, formatNodes, generateId, indentBlock, insertAt, insertBlockAfter, mergeAdjacentNodes, mergeBlocks, moveBlock, outdentBlock, push, redo, removeLink, replaceRange, serialize, serializeNodes, setLink, splitBlock, toMarkdown, toPlainText, toggleBold, toggleColor, toggleHighlight, toggleItalic, toggleStrikethrough, toggleTodo, toggleUnderline, undo };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // src/utils/block.ts
1
+ // src/engine/block.ts
2
2
  import { Result } from "@reiwuzen/result";
3
3
  import { v7 } from "uuid";
4
4
  function generateId(fn) {
@@ -37,12 +37,28 @@ function createBlock(type, idFn) {
37
37
  return block;
38
38
  });
39
39
  }
40
- function insertBlockAfter(blocks, afterId, type, idFn) {
41
- return createBlock(type, idFn).map((newBlock) => {
40
+ function createBlockAfter(blocks, afterId, type, idFn) {
41
+ return createBlock(type, idFn).andThen((newBlock) => {
42
42
  const index = blocks.findIndex((b) => b.id === afterId);
43
+ if (index === -1) return Result.Err(`[BlockNotFound]: ${afterId}`);
43
44
  const next = [...blocks];
44
45
  next.splice(index + 1, 0, newBlock);
45
- return { blocks: next, newId: newBlock.id };
46
+ return Result.Ok({ blocks: next, newId: newBlock.id });
47
+ });
48
+ }
49
+ function insertBlockAfter(blocks, afterId, insertBlock) {
50
+ const targetBlockIndex = blocks.findIndex((b) => b.id === afterId);
51
+ if (targetBlockIndex === -1) {
52
+ return Result.Err(`No block found with id: ${afterId}`);
53
+ }
54
+ const newBlocks = [
55
+ ...blocks.slice(0, targetBlockIndex + 1),
56
+ insertBlock,
57
+ ...blocks.slice(targetBlockIndex + 1)
58
+ ];
59
+ return Result.Ok({
60
+ blocks: newBlocks,
61
+ newFocusId: insertBlock.id
46
62
  });
47
63
  }
48
64
  function deleteBlock(blocks, id) {
@@ -50,8 +66,19 @@ function deleteBlock(blocks, id) {
50
66
  const prevId = blocks[index - 1]?.id ?? blocks[index + 1]?.id ?? "";
51
67
  return { blocks: blocks.filter((b) => b.id !== id), prevId };
52
68
  }
53
- function duplicateBlock(incoming, newId) {
54
- return { ...incoming, id: newId };
69
+ function duplicateBlock(incoming, newId = crypto.randomUUID()) {
70
+ return {
71
+ ...incoming,
72
+ id: newId,
73
+ meta: { ...incoming.meta },
74
+ content: incoming.content.map((node) => ({ ...node }))
75
+ };
76
+ }
77
+ function duplicateBlockAfter(blocks, id, newId) {
78
+ const targetBlock = blocks.find((b) => b.id === id);
79
+ if (targetBlock == null) return Result.Err(`[BlockNotFound]: ${id}`);
80
+ const dup = duplicateBlock(targetBlock, newId);
81
+ return insertBlockAfter(blocks, id, dup);
55
82
  }
56
83
  function moveBlock(blocks, id, direction) {
57
84
  const index = blocks.findIndex((b) => b.id === id);
@@ -367,25 +394,56 @@ function splitBlock(block, nodeIndex, offset) {
367
394
  const targetLen = target ? getTextLength(target) : 0;
368
395
  if (offset < 0 || offset > targetLen)
369
396
  return Result3.Err(`offset (${offset}) out of bounds`);
397
+ const atStart = nodeIndex === 0 && offset === 0;
398
+ const atEnd = nodeIndex === nodes.length - 1 && offset === targetLen;
370
399
  const beforeNodes = [
371
400
  ...nodes.slice(0, nodeIndex),
372
- ...target && offset > 0 ? [sliceNode(target, 0, offset)].filter(Boolean) : []
401
+ ...target && offset > 0 ? [sliceNode(target, 0, offset)].filter(isNode) : []
373
402
  ];
374
403
  const afterNodes = [
375
- ...target && offset < targetLen ? [sliceNode(target, offset, targetLen)].filter(Boolean) : [],
404
+ ...target && offset < targetLen ? [sliceNode(target, offset, targetLen)].filter(isNode) : [],
376
405
  ...nodes.slice(nodeIndex + 1)
377
406
  ];
378
- const original = {
407
+ if (atStart) {
408
+ const left2 = {
409
+ ...block,
410
+ content: []
411
+ };
412
+ const right2 = {
413
+ id: generateId(),
414
+ type: block.type,
415
+ content: nodes,
416
+ meta: {}
417
+ };
418
+ return Result3.Ok([left2, right2]);
419
+ }
420
+ if (atEnd) {
421
+ const left2 = {
422
+ ...block,
423
+ content: nodes
424
+ };
425
+ const right2 = {
426
+ id: generateId(),
427
+ type: "paragraph",
428
+ content: [],
429
+ meta: {}
430
+ };
431
+ return Result3.Ok([left2, right2]);
432
+ }
433
+ const left = {
379
434
  ...block,
380
435
  content: beforeNodes
381
436
  };
382
- const newBlock = {
437
+ const right = {
383
438
  id: generateId(),
384
439
  type: "paragraph",
385
440
  content: afterNodes,
386
441
  meta: {}
387
442
  };
388
- return Result3.Ok([original, newBlock]);
443
+ return Result3.Ok([left, right]);
444
+ }
445
+ function isNode(node) {
446
+ return node !== null;
389
447
  }
390
448
  function mergeBlocks(blockA, blockB) {
391
449
  if (blockA.type === "code" || blockA.type === "equation")
@@ -439,18 +497,6 @@ function stripPrefix(block, prefix) {
439
497
  if (stripped.length === 0) return [];
440
498
  return [{ ...first, text: stripped }, ...block.content.slice(1)];
441
499
  }
442
- function buildMeta(type) {
443
- switch (type) {
444
- case "bullet":
445
- case "number":
446
- case "todo":
447
- return { depth: 0 };
448
- case "heading1":
449
- case "heading2":
450
- case "heading3":
451
- return {};
452
- }
453
- }
454
500
  function applyMarkdownTransform(block, cursorOffset) {
455
501
  if (block.type !== "paragraph")
456
502
  return Result4.Ok({ block, converted: false });
@@ -467,7 +513,7 @@ function applyMarkdownTransform(block, cursorOffset) {
467
513
  id: block.id,
468
514
  type: targetType,
469
515
  content: strippedContent,
470
- meta: buildMeta(targetType)
516
+ meta: buildMetaForTarget(targetType)
471
517
  };
472
518
  return Result4.Ok({ block: converted, converted: true });
473
519
  }
@@ -694,67 +740,8 @@ ${block.content[0].text}
694
740
  }).join("\n\n");
695
741
  }
696
742
 
697
- // src/engine/cursor.ts
698
- import { Result as Result6 } from "@reiwuzen/result";
699
- function getTextLength2(node) {
700
- if (node.type === "text" || node.type === "code") return node.text.length;
701
- if (node.type === "equation") return node.latex.length;
702
- return 0;
703
- }
704
- function flatToPosition(block, flatOffset) {
705
- if (block.type === "code" || block.type === "equation") {
706
- const node = block.content[0];
707
- const len = getTextLength2(node);
708
- if (flatOffset < 0 || flatOffset > len)
709
- return Result6.Err(`flatOffset (${flatOffset}) out of bounds (length=${len})`);
710
- return Result6.Ok({ nodeIndex: 0, offset: flatOffset });
711
- }
712
- const nodes = block.content;
713
- if (flatOffset < 0)
714
- return Result6.Err(`flatOffset (${flatOffset}) cannot be negative`);
715
- let accumulated = 0;
716
- for (let i = 0; i < nodes.length; i++) {
717
- const len = getTextLength2(nodes[i]);
718
- if (flatOffset <= accumulated + len) {
719
- return Result6.Ok({ nodeIndex: i, offset: flatOffset - accumulated });
720
- }
721
- accumulated += len;
722
- }
723
- if (flatOffset === accumulated) {
724
- const last = nodes.length - 1;
725
- return Result6.Ok({ nodeIndex: Math.max(0, last), offset: getTextLength2(nodes[last]) });
726
- }
727
- return Result6.Err(
728
- `flatOffset (${flatOffset}) out of bounds (total length=${accumulated})`
729
- );
730
- }
731
- function flatToSelection(block, start, end) {
732
- if (start > end)
733
- return Result6.Err(`start (${start}) cannot be greater than end (${end})`);
734
- return flatToPosition(block, start).andThen(
735
- (startPos) => flatToPosition(block, end).map((endPos) => ({
736
- startIndex: startPos.nodeIndex,
737
- startOffset: startPos.offset,
738
- endIndex: endPos.nodeIndex,
739
- endOffset: endPos.offset
740
- }))
741
- );
742
- }
743
- function positionToFlat(block, nodeIndex, offset) {
744
- if (block.type === "code" || block.type === "equation")
745
- return Result6.Ok(offset);
746
- const nodes = block.content;
747
- if (nodeIndex < 0 || nodeIndex >= nodes.length)
748
- return Result6.Err(`nodeIndex (${nodeIndex}) out of bounds`);
749
- let flat = 0;
750
- for (let i = 0; i < nodeIndex; i++) {
751
- flat += getTextLength2(nodes[i]);
752
- }
753
- return Result6.Ok(flat + offset);
754
- }
755
-
756
743
  // src/engine/history.ts
757
- import { Result as Result7 } from "@reiwuzen/result";
744
+ import { Result as Result6 } from "@reiwuzen/result";
758
745
  function createHistory(initialBlocks) {
759
746
  return {
760
747
  past: [],
@@ -772,9 +759,9 @@ function push(history, blocks, maxSize = 100) {
772
759
  }
773
760
  function undo(history) {
774
761
  if (history.past.length === 0)
775
- return Result7.Err("Nothing to undo");
762
+ return Result6.Err("Nothing to undo");
776
763
  const previous = history.past[history.past.length - 1];
777
- return Result7.Ok({
764
+ return Result6.Ok({
778
765
  past: history.past.slice(0, -1),
779
766
  present: previous,
780
767
  future: [history.present, ...history.future]
@@ -782,9 +769,9 @@ function undo(history) {
782
769
  }
783
770
  function redo(history) {
784
771
  if (history.future.length === 0)
785
- return Result7.Err("Nothing to redo");
772
+ return Result6.Err("Nothing to redo");
786
773
  const next = history.future[0];
787
- return Result7.Ok({
774
+ return Result6.Ok({
788
775
  past: [...history.past, history.present],
789
776
  present: next,
790
777
  future: history.future.slice(1)
@@ -793,53 +780,13 @@ function redo(history) {
793
780
  var canUndo = (history) => history.past.length > 0;
794
781
  var canRedo = (history) => history.future.length > 0;
795
782
  var currentBlocks = (history) => history.present.blocks;
796
-
797
- // src/engine/block.ts
798
- function blockInsertAt(block, nodeIndex, offset, incoming) {
799
- return insertAt(block, nodeIndex, offset, incoming).map(
800
- (content) => ({
801
- ...block,
802
- content
803
- })
804
- );
805
- }
806
- function blockDeleteLastChar(block) {
807
- return deleteLastChar(block).map(
808
- (content) => ({
809
- ...block,
810
- content
811
- })
812
- );
813
- }
814
- function blockDeleteRange(block, startNodeIndex, startOffset, endNodeIndex, endOffset) {
815
- return deleteRange(
816
- block,
817
- startNodeIndex,
818
- startOffset,
819
- endNodeIndex,
820
- endOffset
821
- ).map((content) => ({ ...block, content }));
822
- }
823
- function blockReplaceRange(block, startNodeIndex, startOffset, endNodeIndex, endOffset, incoming) {
824
- return replaceRange(
825
- block,
826
- startNodeIndex,
827
- startOffset,
828
- endNodeIndex,
829
- endOffset,
830
- incoming
831
- ).map((content) => ({ ...block, content }));
832
- }
833
783
  export {
834
784
  applyMarkdownTransform,
835
- blockDeleteLastChar,
836
- blockDeleteRange,
837
- blockInsertAt,
838
- blockReplaceRange,
839
785
  canRedo,
840
786
  canUndo,
841
787
  changeBlockType,
842
788
  createBlock,
789
+ createBlockAfter,
843
790
  createHistory,
844
791
  currentBlocks,
845
792
  deleteBlock,
@@ -848,8 +795,7 @@ export {
848
795
  deserialize,
849
796
  deserializeNodes,
850
797
  duplicateBlock,
851
- flatToPosition,
852
- flatToSelection,
798
+ duplicateBlockAfter,
853
799
  formatNodes,
854
800
  generateId,
855
801
  indentBlock,
@@ -859,7 +805,6 @@ export {
859
805
  mergeBlocks,
860
806
  moveBlock,
861
807
  outdentBlock,
862
- positionToFlat,
863
808
  push,
864
809
  redo,
865
810
  removeLink,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reiwuzen/blocky",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Pure TypeScript block editor engine. Headless, framework-agnostic — content mutation, formatting, transforms, serialization, and history.",
5
5
  "author": "Rei WuZen",
6
6
  "license": "ISC",