@jackuait/blok 0.10.0-beta.15 → 0.10.0-beta.16

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.
@@ -40,6 +40,33 @@ export function sanitize(wrapper: HTMLElement): void {
40
40
  sanitizeNode(wrapper);
41
41
  }
42
42
 
43
+ /**
44
+ * Unwrap a disallowed element: move its children before it, remove it,
45
+ * and return any element children so they can be re-queued for processing.
46
+ */
47
+ function unwrapElement(el: HTMLElement): ChildNode[] {
48
+ const grandchildren = Array.from(el.childNodes);
49
+
50
+ for (const gc of grandchildren) {
51
+ el.before(gc);
52
+ }
53
+ el.remove();
54
+
55
+ return grandchildren.filter((gc) => gc.nodeType === gc.ELEMENT_NODE);
56
+ }
57
+
58
+ /**
59
+ * Strip attributes from an element that are not in the allowed set.
60
+ * When `allowedAttrs` is `true`, all attributes are removed.
61
+ */
62
+ function stripAttributes(el: HTMLElement, allowedAttrs: Set<string> | true): void {
63
+ for (const attr of Array.from(el.attributes)) {
64
+ if (allowedAttrs === true || !allowedAttrs.has(attr.name)) {
65
+ el.removeAttribute(attr.name);
66
+ }
67
+ }
68
+ }
69
+
43
70
  function sanitizeNode(node: Node): void {
44
71
  // Use a live-like approach: collect children, then process each.
45
72
  // When a child is unwrapped its grandchildren are inserted in place and
@@ -52,43 +79,15 @@ function sanitizeNode(node: Node): void {
52
79
  }
53
80
 
54
81
  const el = child as HTMLElement;
55
- const tag = el.tagName;
56
- const allowedAttrs = ALLOWED[tag];
82
+ const allowedAttrs = ALLOWED[el.tagName];
57
83
 
58
84
  if (allowedAttrs === undefined) {
59
- // Unwrap: move children to the parent, then remove this element.
60
- const grandchildren = Array.from(el.childNodes);
61
-
62
- for (const gc of grandchildren) {
63
- el.before(gc);
64
- }
65
-
66
- el.remove();
67
-
68
- // Push the moved grandchildren onto the queue so they are evaluated
69
- // for unwrapping / attribute-stripping in the same parent context.
70
- for (const gc of grandchildren) {
71
- if (gc.nodeType === gc.ELEMENT_NODE) {
72
- queue.push(gc);
73
- }
74
- }
75
- } else {
76
- // Strip disallowed attributes.
77
- if (allowedAttrs !== true) {
78
- for (const attr of Array.from(el.attributes)) {
79
- if (!allowedAttrs.has(attr.name)) {
80
- el.removeAttribute(attr.name);
81
- }
82
- }
83
- } else {
84
- // true means no attributes allowed — strip all.
85
- for (const attr of Array.from(el.attributes)) {
86
- el.removeAttribute(attr.name);
87
- }
88
- }
89
-
90
- // Recurse into children.
91
- sanitizeNode(el);
85
+ // Unwrap disallowed tag and re-queue its element children.
86
+ queue.push(...unwrapElement(el));
87
+ continue;
92
88
  }
89
+
90
+ stripAttributes(el, allowedAttrs);
91
+ sanitizeNode(el);
93
92
  }
94
93
  }
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ const HELP_TEXT = `Usage: blok-cli [options]
5
5
 
6
6
  Options:
7
7
  --convert-html Convert legacy HTML from stdin to Blok JSON (stdout)
8
+ --convert-gdocs Convert Google Docs HTML from stdin to Blok JSON (stdout)
8
9
  --migration Output the EditorJS to Blok migration guide (LLM-friendly)
9
10
  --output <file> Write output to a file instead of stdout
10
11
  --help Show this help message
@@ -12,6 +13,8 @@ Options:
12
13
  Examples:
13
14
  npx @jackuait/blok-cli --convert-html < article.html
14
15
  npx @jackuait/blok-cli --convert-html < article.html --output article.json
16
+ npx @jackuait/blok-cli --convert-gdocs < gdocs-export.html
17
+ npx @jackuait/blok-cli --convert-gdocs < gdocs-export.html --output doc.json
15
18
  npx @jackuait/blok-cli --migration
16
19
  npx @jackuait/blok-cli --migration | pbcopy
17
20
  npx @jackuait/blok-cli --migration --output migration-guide.md
@@ -29,6 +32,13 @@ const parseArgs = (argv: string[]): { command: string | null; output?: string }
29
32
  return { command: 'convert-html', output };
30
33
  }
31
34
 
35
+ if (argv.includes('--convert-gdocs')) {
36
+ const outputIndex = argv.indexOf('--output');
37
+ const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
38
+
39
+ return { command: 'convert-gdocs', output };
40
+ }
41
+
32
42
  if (argv.includes('--migration')) {
33
43
  const outputIndex = argv.indexOf('--output');
34
44
  const output = outputIndex !== -1 ? argv[outputIndex + 1] : undefined;
@@ -44,7 +54,7 @@ export const run = async (argv: string[], version: string): Promise<void> => {
44
54
 
45
55
  switch (command) {
46
56
  case 'convert-html': {
47
- const jsdom = await import('jsdom') as { JSDOM: new (html: string) => { window: typeof globalThis } };
57
+ const jsdom = await import('jsdom');
48
58
  const dom = new jsdom.JSDOM('');
49
59
 
50
60
  globalThis.DOMParser = dom.window.DOMParser;
@@ -58,6 +68,22 @@ export const run = async (argv: string[], version: string): Promise<void> => {
58
68
  writeOutput(json, output);
59
69
  break;
60
70
  }
71
+ case 'convert-gdocs': {
72
+ const jsdom = await import('jsdom');
73
+ const dom = new jsdom.JSDOM('');
74
+
75
+ globalThis.DOMParser = dom.window.DOMParser;
76
+ globalThis.Node = dom.window.Node;
77
+ (globalThis as Record<string, unknown>).document = dom.window.document;
78
+
79
+ const { convertGdocs } = await import('./commands/convert-gdocs/index');
80
+ const fs = await import('node:fs');
81
+ const html = fs.readFileSync('/dev/stdin', 'utf-8');
82
+ const json = convertGdocs(html);
83
+
84
+ writeOutput(json, output);
85
+ break;
86
+ }
61
87
  case 'migration': {
62
88
  const content = getMigrationDoc(version);
63
89
 
@@ -526,13 +526,14 @@ export class Toolbar extends Module<ToolbarNodes> {
526
526
  * so toolbar buttons align with the block content edge, even when
527
527
  * consumer CSS overrides the block content's margin.
528
528
  *
529
- * Runs after open() so the toolbar is visible and actions have correct offsetWidth.
529
+ * Uses the block's actual marginLeft directly when content is left-aligned
530
+ * (marginLeft: 0), toolbar actions will extend into the editor's left gutter
531
+ * via negative positioning (right: 100%), which is the expected behavior.
530
532
  */
531
533
  if (blockContentElement && this.nodes.content) {
532
534
  const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
533
- const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
534
535
 
535
- this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
536
+ this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
536
537
  }
537
538
  }
538
539
 
@@ -654,13 +655,11 @@ export class Toolbar extends Module<ToolbarNodes> {
654
655
 
655
656
  /**
656
657
  * Sync toolbar content wrapper's margin with the block content element.
657
- * Runs after open() so the toolbar is visible and actions have correct offsetWidth.
658
658
  */
659
659
  if (blockContentElement && this.nodes.content) {
660
660
  const blockMarginLeft = parseFloat(getComputedStyle(blockContentElement).marginLeft) || 0;
661
- const actionsWidth = this.nodes.actions?.offsetWidth ?? 0;
662
661
 
663
- this.nodes.content.style.marginLeft = `${Math.max(blockMarginLeft, actionsWidth)}px`;
662
+ this.nodes.content.style.marginLeft = `${blockMarginLeft}px`;
664
663
  }
665
664
  }
666
665
 
@@ -54,6 +54,7 @@ import type { PendingHighlight } from './table-row-col-action-handler';
54
54
  import { TableRowColControls } from './table-row-col-controls';
55
55
  import type { RowColAction } from './table-row-col-controls';
56
56
  import { registerAdditionalRestrictedTools } from './table-restrictions';
57
+ import { TableCornerDrag } from './table-corner-drag';
57
58
  import { TableScrollHaze } from './table-scroll-haze';
58
59
  import type { CellPlacement, ClipboardBlockData, LegacyCellContent, TableCellsClipboard, TableData, TableConfig } from './types';
59
60
  import { isCellWithBlocks } from './types';
@@ -94,6 +95,7 @@ export class Table implements BlockTool {
94
95
  private rowColControls: TableRowColControls | null = null;
95
96
  private cellBlocks: TableCellBlocks | null = null;
96
97
  private cellSelection: TableCellSelection | null = null;
98
+ private cornerDrag: TableCornerDrag | null = null;
97
99
  private scrollHaze: TableScrollHaze | null = null;
98
100
  private element: HTMLDivElement | null = null;
99
101
  private gridElement: HTMLElement | null = null;
@@ -196,6 +198,8 @@ export class Table implements BlockTool {
196
198
  this.resize = null;
197
199
  this.addControls?.destroy();
198
200
  this.addControls = null;
201
+ this.cornerDrag?.destroy();
202
+ this.cornerDrag = null;
199
203
  this.rowColControls?.destroy();
200
204
  this.rowColControls = null;
201
205
  this.cellSelection?.destroy();
@@ -333,6 +337,7 @@ export class Table implements BlockTool {
333
337
  private initSubsystems(gridEl: HTMLElement): void {
334
338
  this.initResize(gridEl);
335
339
  this.initAddControls(gridEl);
340
+ this.initCornerDrag(gridEl);
336
341
  this.initRowColControls(gridEl);
337
342
  this.initCellSelection(gridEl);
338
343
  this.initGridPasteListener(gridEl);
@@ -989,6 +994,10 @@ export class Table implements BlockTool {
989
994
  wrapper: this.element,
990
995
  grid: gridEl,
991
996
  i18n: this.api.i18n,
997
+ getTableSize: () => ({
998
+ rows: this.model.rows,
999
+ cols: this.model.cols,
1000
+ }),
992
1001
  getNewColumnWidth: () => {
993
1002
  const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
994
1003
 
@@ -1129,6 +1138,131 @@ export class Table implements BlockTool {
1129
1138
  }
1130
1139
  }
1131
1140
 
1141
+ private initCornerDrag(gridEl: HTMLElement): void {
1142
+ this.cornerDrag?.destroy();
1143
+
1144
+ if (!this.element) {
1145
+ return;
1146
+ }
1147
+
1148
+ this.cornerDrag = new TableCornerDrag({
1149
+ wrapper: this.element,
1150
+ gridEl,
1151
+ onAddRow: () => {
1152
+ this.runStructuralOp(() => {
1153
+ this.grid.addRow(gridEl);
1154
+ this.model.addRow();
1155
+ populateNewCells(gridEl, this.cellBlocks);
1156
+ updateHeadingStyles(this.gridElement, this.model.withHeadings);
1157
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1158
+ });
1159
+ },
1160
+ onAddColumn: () => {
1161
+ this.runStructuralOp(() => {
1162
+ const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
1163
+ const halfWidth = this.model.initialColWidth !== undefined
1164
+ ? Math.round((this.model.initialColWidth / 2) * 100) / 100
1165
+ : computeHalfAvgWidth(colWidths);
1166
+ const newWidths = [...colWidths, halfWidth];
1167
+
1168
+ this.grid.addColumn(gridEl, undefined, colWidths, halfWidth);
1169
+ this.model.addColumn(undefined, halfWidth);
1170
+ this.model.setColWidths(newWidths);
1171
+ applyPixelWidths(gridEl, newWidths);
1172
+ enableScrollOverflow(this.ensureScrollContainer());
1173
+ populateNewCells(gridEl, this.cellBlocks);
1174
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1175
+ });
1176
+ },
1177
+ onRemoveLastRow: () => {
1178
+ this.runStructuralOp(() => {
1179
+ const rowCount = this.grid.getRowCount(gridEl);
1180
+
1181
+ if (rowCount <= 1) {
1182
+ return;
1183
+ }
1184
+
1185
+ const { blocksToDelete } = this.model.deleteRow(rowCount - 1);
1186
+
1187
+ this.cellBlocks?.deleteBlocks(blocksToDelete);
1188
+ this.grid.deleteRow(gridEl, rowCount - 1);
1189
+ });
1190
+ },
1191
+ onRemoveLastColumn: () => {
1192
+ this.runStructuralOp(() => {
1193
+ const colCount = this.grid.getColumnCount(gridEl);
1194
+
1195
+ if (colCount <= 1) {
1196
+ return;
1197
+ }
1198
+
1199
+ const { blocksToDelete } = this.model.deleteColumn(colCount - 1);
1200
+
1201
+ this.cellBlocks?.deleteBlocks(blocksToDelete);
1202
+ this.grid.deleteColumn(gridEl, colCount - 1);
1203
+
1204
+ const updatedWidths = this.model.colWidths;
1205
+
1206
+ if (updatedWidths) {
1207
+ applyPixelWidths(gridEl, updatedWidths);
1208
+ }
1209
+ });
1210
+ },
1211
+ onDragStart: () => {
1212
+ if (this.resize) {
1213
+ this.resize.enabled = false;
1214
+ }
1215
+ this.rowColControls?.hideAllGrips();
1216
+ this.rowColControls?.setGripsDisplay(false);
1217
+ this.addControls?.setDisplay(false);
1218
+ },
1219
+ onDragEnd: () => {
1220
+ this.initResize(gridEl);
1221
+ this.rowColControls?.refresh();
1222
+ this.addControls?.setDisplay(true);
1223
+ this.addControls?.syncRowButtonWidth();
1224
+ },
1225
+ getTableSize: () => {
1226
+ return { rows: this.model.rows, cols: this.model.cols };
1227
+ },
1228
+ canRemoveLastRow: () => {
1229
+ return this.model.rows > 1 && isRowEmpty(gridEl, this.model.rows - 1);
1230
+ },
1231
+ canRemoveLastColumn: () => {
1232
+ return this.model.cols > 1 && isColumnEmpty(gridEl, this.model.cols - 1);
1233
+ },
1234
+ onClickAdd: () => {
1235
+ this.runTransactedStructuralOp(() => {
1236
+ // Add row
1237
+ this.grid.addRow(gridEl);
1238
+ this.model.addRow();
1239
+ populateNewCells(gridEl, this.cellBlocks);
1240
+ updateHeadingStyles(this.gridElement, this.model.withHeadings);
1241
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1242
+
1243
+ // Add column
1244
+ const colWidths = this.model.colWidths ?? readPixelWidths(gridEl);
1245
+ const halfWidth = this.model.initialColWidth !== undefined
1246
+ ? Math.round((this.model.initialColWidth / 2) * 100) / 100
1247
+ : computeHalfAvgWidth(colWidths);
1248
+ const newWidths = [...colWidths, halfWidth];
1249
+
1250
+ this.grid.addColumn(gridEl, undefined, colWidths, halfWidth);
1251
+ this.model.addColumn(undefined, halfWidth);
1252
+ this.model.setColWidths(newWidths);
1253
+ applyPixelWidths(gridEl, newWidths);
1254
+ populateNewCells(gridEl, this.cellBlocks);
1255
+ updateHeadingColumnStyles(this.gridElement, this.model.withHeadingColumn);
1256
+
1257
+ // Refresh subsystems
1258
+ this.initResize(gridEl);
1259
+ this.rowColControls?.refresh();
1260
+ this.addControls?.syncRowButtonWidth();
1261
+ });
1262
+ },
1263
+ });
1264
+ }
1265
+
1132
1266
  private initRowColControls(gridEl: HTMLElement): void {
1133
1267
  this.rowColControls?.destroy();
1134
1268
 
@@ -1152,6 +1286,7 @@ export class Table implements BlockTool {
1152
1286
  }
1153
1287
 
1154
1288
  this.addControls?.setDisplay(!isDragging);
1289
+ this.cornerDrag?.setDisplay(!isDragging);
1155
1290
 
1156
1291
  if (isDragging) {
1157
1292
  this.api.toolbar.close({ setExplicitlyClosed: false });
@@ -1531,6 +1666,7 @@ export class Table implements BlockTool {
1531
1666
  }
1532
1667
 
1533
1668
  this.addControls?.setInteractive(!hasSelection);
1669
+ this.cornerDrag?.setInteractive(!hasSelection);
1534
1670
  this.rowColControls?.setGripsDisplay(!hasSelection);
1535
1671
  },
1536
1672
  onSelectionRangeChange: () => {
@@ -1,7 +1,7 @@
1
1
  import type { I18n } from '../../../types/api';
2
2
  import { IconPlus } from '../../components/icons';
3
3
  import { createTooltipContent } from '../../components/modules/toolbar/tooltip';
4
- import { hide as hideTooltip, onHover } from '../../components/utils/tooltip';
4
+ import { hide as hideTooltip, onHover, show as showTooltip } from '../../components/utils/tooltip';
5
5
  import { twMerge } from '../../components/utils/tw';
6
6
 
7
7
  const ADD_ROW_ATTR = 'data-blok-table-add-row';
@@ -56,6 +56,7 @@ interface TableAddControlsOptions {
56
56
  onDragAddCol: () => void;
57
57
  onDragRemoveCol: () => void;
58
58
  onDragEnd: () => void;
59
+ getTableSize: () => { rows: number; cols: number };
59
60
  /** Returns the pixel width of a newly added column, used as the drag unit size. */
60
61
  getNewColumnWidth?: () => number;
61
62
  }
@@ -94,6 +95,7 @@ export class TableAddControls {
94
95
  private boundPointerCancel: (e: PointerEvent) => void;
95
96
  private boundRowPointerDown: (e: PointerEvent) => void;
96
97
  private boundColPointerDown: (e: PointerEvent) => void;
98
+ private getTableSize: () => { rows: number; cols: number };
97
99
  private getNewColumnWidth: (() => number) | undefined;
98
100
  private scrollContainer: HTMLElement | null = null;
99
101
  private boundScrollHandler: (() => void) | null = null;
@@ -112,6 +114,7 @@ export class TableAddControls {
112
114
  this.onDragAddCol = options.onDragAddCol;
113
115
  this.onDragRemoveCol = options.onDragRemoveCol;
114
116
  this.onDragEnd = options.onDragEnd;
117
+ this.getTableSize = options.getTableSize;
115
118
  this.getNewColumnWidth = options.getNewColumnWidth;
116
119
  this.boundMouseMove = this.handleMouseMove.bind(this);
117
120
  this.boundDocumentMouseMove = this.handleDocumentMouseMove.bind(this);
@@ -324,6 +327,20 @@ export class TableAddControls {
324
327
  this.addColBtn.remove();
325
328
  }
326
329
 
330
+ private showDimensionTooltip(): void {
331
+ if (!this.dragState) {
332
+ return;
333
+ }
334
+
335
+ const size = this.getTableSize();
336
+ const target = this.dragState.axis === 'row' ? this.addRowBtn : this.addColBtn;
337
+ const opts = this.dragState.axis === 'row'
338
+ ? { placement: 'bottom' as const, marginTop: -16 }
339
+ : { placement: 'bottom' as const };
340
+
341
+ showTooltip(target, `${size.cols}\u00D7${size.rows}`, opts);
342
+ }
343
+
327
344
  private handlePointerDown(axis: 'row' | 'col', e: PointerEvent): void {
328
345
  e.preventDefault();
329
346
 
@@ -380,8 +397,18 @@ export class TableAddControls {
380
397
  if (Math.abs(delta) > DRAG_THRESHOLD && !this.dragState.didDrag) {
381
398
  this.dragState.didDrag = true;
382
399
  document.body.style.cursor = axis === 'row' ? 'row-resize' : 'col-resize';
383
- hideTooltip();
400
+ this.showDimensionTooltip();
384
401
  this.onDragStart();
402
+
403
+ return;
404
+ }
405
+
406
+ if (this.dragState.didDrag) {
407
+ this.showDimensionTooltip();
408
+ }
409
+
410
+ if (this.dragState.didDrag) {
411
+ this.showDimensionTooltip();
385
412
  }
386
413
  }
387
414
 
@@ -400,6 +427,7 @@ export class TableAddControls {
400
427
  target.removeEventListener('pointercancel', this.boundPointerCancel);
401
428
 
402
429
  document.body.style.cursor = '';
430
+ hideTooltip();
403
431
  this.dragState = null;
404
432
 
405
433
  if (!didDrag) {
@@ -431,6 +459,7 @@ export class TableAddControls {
431
459
  target.removeEventListener('pointercancel', this.boundPointerCancel);
432
460
 
433
461
  document.body.style.cursor = '';
462
+ hideTooltip();
434
463
  this.dragState = null;
435
464
 
436
465
  if (didDrag) {
@@ -1053,7 +1053,11 @@ export class TableCellBlocks {
1053
1053
  }
1054
1054
 
1055
1055
  /**
1056
- * Delete blocks by their IDs (in reverse index order to avoid shifting issues)
1056
+ * Delete blocks by their IDs (in reverse index order to avoid shifting issues).
1057
+ * Preserves scroll position because api.blocks.delete() is async — its internal
1058
+ * `await` defers Caret.setToBlock() to microtasks that run AFTER this method returns,
1059
+ * causing unwanted page jumps via element.focus() and window.scrollBy().
1060
+ * We use Promise.all().then() to schedule the scroll restore after all those microtasks.
1057
1061
  */
1058
1062
  public deleteBlocks(blockIds: string[]): void {
1059
1063
  const blockIndices = blockIds
@@ -1061,8 +1065,14 @@ export class TableCellBlocks {
1061
1065
  .filter((index): index is number => index !== undefined)
1062
1066
  .sort((a, b) => b - a);
1063
1067
 
1064
- blockIndices.forEach(index => {
1065
- void this.api.blocks.delete(index);
1068
+ const savedScrollY = window.scrollY;
1069
+
1070
+ const deletePromises = blockIndices.map(index => this.api.blocks.delete(index));
1071
+
1072
+ void Promise.all(deletePromises).then(() => {
1073
+ if (window.scrollY !== savedScrollY) {
1074
+ window.scrollTo(0, savedScrollY);
1075
+ }
1066
1076
  });
1067
1077
  }
1068
1078