@jackuait/blok 0.7.3-beta.3 → 0.7.3

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 (68) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-CdxHhr5i.mjs → blok-BmlbETK7.mjs} +2119 -2013
  3. package/dist/chunks/{constants-C_H9o9Ao.mjs → constants-WhLyFkza.mjs} +260 -223
  4. package/dist/chunks/{i18next-loader-D5HxE5ZQ.mjs → i18next-loader-CZARkla1.mjs} +1 -1
  5. package/dist/chunks/{lightweight-i18n-Safdy0ua.mjs → lightweight-i18n-BQa0F2X6.mjs} +9 -0
  6. package/dist/chunks/{tools-B0YXCZFW.mjs → tools-BCb5bMO3.mjs} +973 -843
  7. package/dist/full.mjs +3 -3
  8. package/dist/locales.mjs +9 -0
  9. package/dist/react.mjs +2 -2
  10. package/dist/tools.mjs +2 -2
  11. package/package.json +1 -1
  12. package/src/components/block/style-manager.ts +1 -1
  13. package/src/components/blocks.ts +26 -54
  14. package/src/components/constants/data-attributes.ts +0 -2
  15. package/src/components/i18n/locales/en/messages.json +9 -0
  16. package/src/components/icons/index.ts +34 -6
  17. package/src/components/inline-tools/inline-tool-link.ts +202 -5
  18. package/src/components/inline-tools/inline-tool-marker.ts +166 -23
  19. package/src/components/inline-tools/utils/formatting-range-utils.ts +10 -1
  20. package/src/components/modules/blockManager/blockManager.ts +2 -2
  21. package/src/components/modules/blockManager/operations.ts +2 -2
  22. package/src/components/modules/blockManager/repository.ts +1 -9
  23. package/src/components/modules/blockManager/types.ts +1 -1
  24. package/src/components/modules/drag/operations/DragOperations.ts +45 -6
  25. package/src/components/modules/paste/google-docs-preprocessor.ts +69 -2
  26. package/src/components/modules/paste/handlers/blok-data-handler.ts +96 -19
  27. package/src/components/modules/renderer.ts +2 -0
  28. package/src/components/modules/toolbar/blockSettings.ts +1 -1
  29. package/src/components/modules/toolbar/index.ts +21 -0
  30. package/src/components/modules/toolbar/plus-button.ts +15 -5
  31. package/src/components/selection/fake-background/index.ts +9 -10
  32. package/src/components/shared/color-picker.ts +108 -95
  33. package/src/components/shared/color-presets.ts +30 -2
  34. package/src/components/ui/toolbox.ts +36 -7
  35. package/src/components/utils/color-mapping.ts +43 -1
  36. package/src/components/utils/color-migration.ts +37 -0
  37. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts +4 -3
  38. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +5 -39
  39. package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts +2 -2
  40. package/src/components/utils/popover/components/popover-item/popover-item.ts +11 -0
  41. package/src/components/utils/popover/components/search-input/search-input.const.ts +2 -3
  42. package/src/components/utils/popover/components/search-input/search-input.ts +1 -32
  43. package/src/components/utils/popover/popover-abstract.ts +2 -4
  44. package/src/components/utils/popover/popover-desktop.ts +1 -16
  45. package/src/components/utils/popover/popover-inline.ts +1 -2
  46. package/src/components/utils/popover/popover-mobile.ts +2 -2
  47. package/src/components/utils/popover/popover.const.ts +1 -1
  48. package/src/stories/Table.stories.ts +15 -9
  49. package/src/styles/main.css +312 -14
  50. package/src/tools/header/index.ts +5 -5
  51. package/src/tools/list/constants.ts +11 -4
  52. package/src/tools/list/depth-validator.ts +13 -1
  53. package/src/tools/list/dom-builder.ts +5 -3
  54. package/src/tools/list/index.ts +3 -2
  55. package/src/tools/paragraph/index.ts +2 -2
  56. package/src/tools/table/table-cell-color-picker.ts +1 -1
  57. package/src/tools/table/table-cell-selection.ts +1 -2
  58. package/src/tools/table/table-core.ts +2 -2
  59. package/src/tools/table/table-grip-visuals.ts +13 -5
  60. package/src/tools/table/table-heading-toggle.ts +15 -9
  61. package/src/tools/table/table-row-col-controls.ts +17 -11
  62. package/src/tools/table/table-row-col-drag.ts +26 -3
  63. package/src/tools/toggle/constants.ts +5 -5
  64. package/src/tools/toggle/index.ts +1 -1
  65. package/types/tools/hook-events.d.ts +6 -0
  66. package/types/tools/toggle.d.ts +23 -0
  67. package/types/tools-entry.d.ts +3 -0
  68. package/types/utils/popover/popover-item.d.ts +6 -0
@@ -15,6 +15,8 @@ import {
15
15
  } from './utils/formatting-range-utils';
16
16
  import { createColorPicker } from '../shared/color-picker';
17
17
  import type { ColorPickerHandle } from '../shared/color-picker';
18
+ import { colorVarName } from '../shared/color-presets';
19
+ import { mapToNearestPresetName } from '../utils/color-mapping';
18
20
 
19
21
  /**
20
22
  * Color mode type — either text color or background color
@@ -122,6 +124,16 @@ export class MarkerInlineTool implements InlineTool {
122
124
  */
123
125
  private colorMode: ColorMode = 'color';
124
126
 
127
+ /**
128
+ * Active text color for bar indicator (null = default/none)
129
+ */
130
+ private activeTextColor: string | null = null;
131
+
132
+ /**
133
+ * Active background color for bar indicator (null = default/none)
134
+ */
135
+ private activeBgColor: string | null = null;
136
+
125
137
  /**
126
138
  * The color picker handle with element and control methods
127
139
  */
@@ -138,7 +150,6 @@ export class MarkerInlineTool implements InlineTool {
138
150
  this.picker = createColorPicker({
139
151
  i18n: this.i18n,
140
152
  testIdPrefix: 'marker',
141
- defaultModeIndex: 0,
142
153
  modes: [
143
154
  { key: 'color', labelKey: 'tools.marker.textColor', presetField: 'text' },
144
155
  { key: 'background-color', labelKey: 'tools.marker.background', presetField: 'bg' },
@@ -151,6 +162,16 @@ export class MarkerInlineTool implements InlineTool {
151
162
  } else {
152
163
  this.removeColor(this.colorMode);
153
164
  }
165
+
166
+ if (modeKey === 'color') {
167
+ this.activeTextColor = color;
168
+ } else {
169
+ this.activeBgColor = color;
170
+ }
171
+
172
+ this.picker.setActiveColor(color, modeKey);
173
+ this.updateToolbarColors(this.activeTextColor, this.activeBgColor);
174
+
154
175
  this.selection.setFakeBackground();
155
176
  this.selection.save();
156
177
  },
@@ -172,12 +193,18 @@ export class MarkerInlineTool implements InlineTool {
172
193
  }
173
194
 
174
195
  const range = selection.getRangeAt(0);
196
+ const formatted = isRangeFormatted(range, isMarkTag, { ignoreWhitespace: true });
197
+
198
+ if (formatted) {
199
+ const colors = this.detectBothSelectionColors(range);
200
+
201
+ this.updateToolbarColors(colors.text, colors.bg);
202
+ }
175
203
 
176
- return isRangeFormatted(range, isMarkTag, { ignoreWhitespace: true });
204
+ return formatted;
177
205
  },
178
206
  children: {
179
207
  hideChevron: true,
180
- width: '200px',
181
208
  items: [
182
209
  {
183
210
  type: PopoverItemType.Html,
@@ -214,6 +241,8 @@ export class MarkerInlineTool implements InlineTool {
214
241
  return;
215
242
  }
216
243
 
244
+ const resolvedValue = this.resolveToVar(value, mode);
245
+
217
246
  /**
218
247
  * Check if the entire selection is already inside a single mark element
219
248
  */
@@ -232,7 +261,7 @@ export class MarkerInlineTool implements InlineTool {
232
261
  range.compareBoundaryPoints(Range.END_TO_END, markRange) >= 0;
233
262
 
234
263
  if (coversAll) {
235
- existingMark.style.setProperty(mode, value);
264
+ existingMark.style.setProperty(mode, resolvedValue);
236
265
  this.ensureTransparentBg(existingMark);
237
266
 
238
267
  return;
@@ -242,7 +271,7 @@ export class MarkerInlineTool implements InlineTool {
242
271
  * Partial selection: split the mark around the selection
243
272
  * so the new color applies only to the selected text
244
273
  */
245
- this.splitMarkAroundRange(existingMark, range, mode, value);
274
+ this.splitMarkAroundRange(existingMark, range, mode, resolvedValue);
246
275
 
247
276
  return;
248
277
  }
@@ -260,7 +289,7 @@ export class MarkerInlineTool implements InlineTool {
260
289
 
261
290
  const mark = document.createElement('mark');
262
291
 
263
- mark.style.setProperty(mode, value);
292
+ mark.style.setProperty(mode, resolvedValue);
264
293
  this.ensureTransparentBg(mark);
265
294
  mark.appendChild(range.extractContents());
266
295
  range.insertNode(mark);
@@ -314,6 +343,13 @@ export class MarkerInlineTool implements InlineTool {
314
343
  ? ancestorEl.closest('mark')?.parentElement ?? ancestorEl
315
344
  : ancestorEl;
316
345
 
346
+ /**
347
+ * Split any marks that extend beyond the selection boundaries so that
348
+ * the removal only affects the selected portion, not the entire mark.
349
+ * (Mirrors the same call in applyColor().)
350
+ */
351
+ this.splitMarksAtBoundaries(range);
352
+
317
353
  const markAncestors = collectFormattingAncestors(range, isMarkTag);
318
354
 
319
355
  for (const mark of markAncestors) {
@@ -362,18 +398,77 @@ export class MarkerInlineTool implements InlineTool {
362
398
  * and detect the current selection's color to highlight the active swatch.
363
399
  */
364
400
  private onPickerOpen(): void {
401
+ this.activeTextColor = null;
402
+ this.activeBgColor = null;
403
+
365
404
  this.picker.reset();
366
405
 
367
- const activeColor = this.detectSelectionColor();
406
+ const colors = this.detectBothSelectionComputedColors();
368
407
 
369
- if (activeColor) {
370
- this.picker.setActiveColor(activeColor.value, activeColor.mode);
408
+ if (colors.text !== null) {
409
+ this.picker.setActiveColor(colors.text, 'color');
410
+ this.activeTextColor = colors.text;
371
411
  }
372
412
 
413
+ if (colors.bg !== null) {
414
+ this.picker.setActiveColor(colors.bg, 'background-color');
415
+ this.activeBgColor = colors.bg;
416
+ }
417
+
418
+ this.updateToolbarColors(this.activeTextColor, this.activeBgColor);
419
+
373
420
  this.selection.setFakeBackground();
374
421
  this.selection.save();
375
422
  }
376
423
 
424
+ /**
425
+ * Update the color indicator on the inline toolbar marker button.
426
+ * Background color is applied via SVG rect fill so it stays clipped to the rounded square.
427
+ * Button background-color is set to transparent to suppress active-state Tailwind selectors.
428
+ * @param textColor - CSS color value for the icon/text, or null to reset
429
+ * @param bgColor - CSS color value for the button background, or null to reset
430
+ */
431
+ private updateToolbarColors(textColor: string | null, bgColor: string | null): void {
432
+ const btn = document.querySelector<HTMLElement>('[data-blok-item-name="marker"]');
433
+
434
+ if (!btn) {
435
+ return;
436
+ }
437
+
438
+ if (textColor !== null) {
439
+ btn.style.setProperty('color', textColor);
440
+ } else if (bgColor !== null) {
441
+ // Suppress active-state blue (text-icon-active-text) when only a background color is set.
442
+ btn.style.setProperty('color', 'var(--blok-text-primary)');
443
+ } else {
444
+ btn.style.removeProperty('color');
445
+ }
446
+
447
+ const rect = btn.querySelector<SVGRectElement>('svg rect');
448
+
449
+ if (bgColor !== null) {
450
+ // Fill the SVG rect so the color is clipped to the rounded square shape.
451
+ // Transparent on the button suppresses active-state bg-icon-active-bg.
452
+ if (rect) {
453
+ rect.style.fill = bgColor;
454
+ }
455
+ btn.style.setProperty('background-color', 'transparent');
456
+ } else if (textColor !== null) {
457
+ // Use a neutral fill when only text color is applied so that:
458
+ // (a) the active-state blue (data-blok-popover-item-active:bg-icon-active-bg) is suppressed, and
459
+ // (b) light text colors remain visible regardless of the toolbar's own background.
460
+ if (rect) {
461
+ rect.style.fill = 'var(--blok-swatch-neutral-bg)';
462
+ }
463
+ btn.style.setProperty('background-color', 'transparent');
464
+ } else {
465
+ if (rect) {
466
+ rect.style.fill = '';
467
+ }
468
+ btn.style.removeProperty('background-color');
469
+ }
470
+ }
471
+
377
472
  /**
378
473
  * Called when the picker popover closes — clean up selection state
379
474
  */
@@ -388,36 +483,67 @@ export class MarkerInlineTool implements InlineTool {
388
483
  }
389
484
 
390
485
  /**
391
- * Detect the color of the current selection's mark element.
392
- * Returns the first color mode found (text color preferred over background).
486
+ * Detect both text and background colors from the mark at the start of a range.
487
+ * Uses the raw inline style value directly. CSS variables are passed through and
488
+ * resolve correctly in a real browser; resolved rgb values are used as-is.
489
+ * @param range - The selection range to inspect
393
490
  */
394
- private detectSelectionColor(): { value: string; mode: string } | null {
491
+ private detectBothSelectionColors(range: Range): { text: string | null; bg: string | null } {
492
+ const mark = findMarkElement(range.startContainer);
493
+
494
+ if (!mark) {
495
+ return { text: null, bg: null };
496
+ }
497
+
498
+ const pickRaw = (prop: string): string | null => {
499
+ const raw = mark.style.getPropertyValue(prop);
500
+
501
+ return raw && raw !== 'transparent' ? raw : null;
502
+ };
503
+
504
+ return {
505
+ text: pickRaw('color'),
506
+ bg: pickRaw('background-color'),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Detect both text and background colors from the current selection's mark element
512
+ * using computed values (resolves CSS variables via getComputedStyle).
513
+ * Used by onPickerOpen() to highlight the correct swatches in both picker sections.
514
+ */
515
+ private detectBothSelectionComputedColors(): { text: string | null; bg: string | null } {
395
516
  const selection = window.getSelection();
396
517
 
397
518
  if (!selection || selection.rangeCount === 0) {
398
- return null;
519
+ return { text: null, bg: null };
399
520
  }
400
521
 
401
522
  const range = selection.getRangeAt(0);
402
523
  const mark = findMarkElement(range.startContainer);
403
524
 
404
525
  if (!mark) {
405
- return null;
526
+ return { text: null, bg: null };
406
527
  }
407
528
 
408
- const textColor = mark.style.getPropertyValue('color');
529
+ const computedStyle = window.getComputedStyle(mark);
409
530
 
410
- if (textColor && textColor !== 'transparent') {
411
- return { value: textColor, mode: 'color' };
412
- }
531
+ const resolveColor = (prop: string): string | null => {
532
+ const raw = mark.style.getPropertyValue(prop);
413
533
 
414
- const bgColor = mark.style.getPropertyValue('background-color');
534
+ if (!raw || raw === 'transparent') {
535
+ return null;
536
+ }
415
537
 
416
- if (bgColor && bgColor !== 'transparent') {
417
- return { value: bgColor, mode: 'background-color' };
418
- }
538
+ const computed = computedStyle.getPropertyValue(prop);
419
539
 
420
- return null;
540
+ return computed && computed !== 'transparent' ? computed : null;
541
+ };
542
+
543
+ return {
544
+ text: resolveColor('color'),
545
+ bg: resolveColor('background-color'),
546
+ };
421
547
  }
422
548
 
423
549
  /**
@@ -745,4 +871,21 @@ export class MarkerInlineTool implements InlineTool {
745
871
  mark.style.setProperty('background-color', 'transparent');
746
872
  }
747
873
  }
874
+
875
+ /**
876
+ * Translate a raw hex color value to its CSS custom property equivalent.
877
+ * If the value is already a CSS var or cannot be mapped, returns it unchanged.
878
+ * @param value - CSS color value from the picker
879
+ * @param mode - 'color' or 'background-color'
880
+ */
881
+ private resolveToVar(value: string, mode: ColorMode): string {
882
+ if (value.startsWith('var(')) {
883
+ return value;
884
+ }
885
+
886
+ const presetMode = mode === 'color' ? 'text' : 'bg';
887
+ const name = mapToNearestPresetName(value, presetMode);
888
+
889
+ return name !== null ? colorVarName(name, presetMode) : value;
890
+ }
748
891
  }
@@ -27,8 +27,17 @@ const nodeIntersectsRange = (range: Range, node: Node): boolean => {
27
27
  * @param range - The range to iterate within
28
28
  */
29
29
  export const createRangeTextWalker = (range: Range): TreeWalker => {
30
+ // When commonAncestorContainer is a text node, it has no descendants —
31
+ // the TreeWalker would find nothing. Use the parent element as root so
32
+ // the text node itself is reachable as a child. The nodeIntersectsRange
33
+ // filter still restricts results to nodes within the range.
34
+ const container = range.commonAncestorContainer;
35
+ const root = container.nodeType === Node.TEXT_NODE
36
+ ? (container.parentNode ?? container)
37
+ : container;
38
+
30
39
  return document.createTreeWalker(
31
- range.commonAncestorContainer,
40
+ root,
32
41
  NodeFilter.SHOW_TEXT,
33
42
  {
34
43
  acceptNode: (node) => {
@@ -922,9 +922,9 @@ export class BlockManager extends Module {
922
922
  /**
923
923
  * Move a block to a new index
924
924
  */
925
- public move(toIndex: number, fromIndex: number = this.currentBlockIndex, skipDOM = false): void {
925
+ public move(toIndex: number, fromIndex: number = this.currentBlockIndex, skipDOM = false, skipMovedHook = false): void {
926
926
  this._currentBlockIndex = this.operations.currentBlockIndexValue;
927
- this.operations.move(toIndex, fromIndex, skipDOM, this.blocksStore);
927
+ this.operations.move(toIndex, fromIndex, skipDOM, this.blocksStore, skipMovedHook);
928
928
  this._currentBlockIndex = this.operations.currentBlockIndexValue;
929
929
  }
930
930
 
@@ -613,7 +613,7 @@ export class BlockOperations {
613
613
  * @param skipDOM - If true, do not manipulate DOM
614
614
  * @param blocksStore - The blocks store to modify
615
615
  */
616
- public move(toIndex: number, fromIndex: number, skipDOM: boolean, blocksStore: BlocksStore): void {
616
+ public move(toIndex: number, fromIndex: number, skipDOM: boolean, blocksStore: BlocksStore, skipMovedHook = false): void {
617
617
  // Make sure indexes are valid and within a valid range
618
618
  if (isNaN(toIndex) || isNaN(fromIndex)) {
619
619
  log(`Warning during 'move' call: incorrect indices provided.`, 'warn');
@@ -642,7 +642,7 @@ export class BlockOperations {
642
642
  this.suppressStopCapturing = true;
643
643
  try {
644
644
  /** Move up current Block */
645
- blocksStore.move(toIndex, fromIndex, skipDOM);
645
+ blocksStore.move(toIndex, fromIndex, skipDOM, skipMovedHook);
646
646
 
647
647
  /**
648
648
  * After the move, the moved block may be at a different index than toIndex
@@ -117,21 +117,13 @@ export class BlockRepository {
117
117
  return undefined;
118
118
  }
119
119
 
120
- const nodes = this.blocksStore.nodes;
121
-
122
120
  const firstLevelBlock = normalizedElement.closest(createSelector(DATA_ATTR.element));
123
121
 
124
122
  if (!firstLevelBlock) {
125
123
  return undefined;
126
124
  }
127
125
 
128
- const index = nodes.indexOf(firstLevelBlock as HTMLElement);
129
-
130
- if (index >= 0) {
131
- return this.blocksStore[index];
132
- }
133
-
134
- return undefined;
126
+ return this.blocks.find((block) => block.holder === firstLevelBlock);
135
127
  }
136
128
 
137
129
  /**
@@ -109,5 +109,5 @@ export type BlocksStore = Blocks & {
109
109
  [index: number]: Block | undefined;
110
110
  insert(index: number, block: Block, replace?: boolean, appendToWorkingArea?: boolean): void;
111
111
  remove(index: number): void;
112
- move(toIndex: number, fromIndex: number, skipDOM?: boolean): void;
112
+ move(toIndex: number, fromIndex: number, skipDOM?: boolean, skipMovedHook?: boolean): void;
113
113
  };
@@ -2,6 +2,7 @@
2
2
  * DragOperations - Handles move and duplicate operations for drag and drop
3
3
  */
4
4
 
5
+ import { BlockToolAPI } from '../../../block';
5
6
  import type { Block } from '../../../block';
6
7
 
7
8
  export interface SavedBlockData {
@@ -22,7 +23,7 @@ export interface DuplicateResult {
22
23
  export interface BlockManagerAdapter {
23
24
  getBlockIndex(block: Block): number;
24
25
  getBlockByIndex(index: number): Block | undefined;
25
- move(toIndex: number, fromIndex: number, needToFocus: boolean): void;
26
+ move(toIndex: number, fromIndex: number, needToFocus: boolean, skipMovedHook?: boolean): void;
26
27
  insert(config: {
27
28
  tool: string;
28
29
  data: Record<string, unknown>;
@@ -269,25 +270,55 @@ export class DragOperations {
269
270
 
270
271
  /**
271
272
  * Moves blocks down (to a higher index)
273
+ *
274
+ * Blocks are processed in reverse order so each lands at its exact final position
275
+ * without index-shifting side-effects. The `moved()` lifecycle hook is suppressed
276
+ * during the loop because depth-validators in list items read neighbour depths from
277
+ * the DOM — in intermediate states those neighbours haven't arrived yet, causing
278
+ * depths to be incorrectly capped. After all blocks are in place the hooks are
279
+ * re-triggered in document order (parent → children) so each block sees correct
280
+ * neighbours when it validates.
272
281
  */
273
282
  private moveBlocksDown(sortedBlocks: Block[], insertIndex: number): void {
274
- // When moving down, start with insertIndex - 1 and decrement for each block
275
- // This ensures blocks maintain their relative order
283
+ const originalIndices = new Map<Block, number>();
284
+
285
+ sortedBlocks.forEach(block => {
286
+ originalIndices.set(block, this.blockManager.getBlockIndex(block));
287
+ });
288
+
276
289
  const reversedBlocks = [...sortedBlocks].reverse();
277
290
 
278
291
  reversedBlocks.forEach((block, index) => {
279
292
  const currentIndex = this.blockManager.getBlockIndex(block);
280
293
  const targetPosition = insertIndex - 1 - index;
281
294
 
282
- this.blockManager.move(targetPosition, currentIndex, false);
295
+ this.blockManager.move(targetPosition, currentIndex, false, true);
296
+ });
297
+
298
+ sortedBlocks.forEach(block => {
299
+ block.call(BlockToolAPI.MOVED, {
300
+ fromIndex: originalIndices.get(block) ?? 0,
301
+ toIndex: this.blockManager.getBlockIndex(block),
302
+ isGroupMove: true,
303
+ });
283
304
  });
284
305
  }
285
306
 
286
307
  /**
287
308
  * Moves blocks up (to a lower index)
309
+ *
310
+ * Forward order is used so parent blocks arrive at their target before children,
311
+ * giving depth-validators the correct predecessor. The `moved()` hook is still
312
+ * suppressed during the loop and re-triggered afterward for symmetry with
313
+ * `moveBlocksDown` and to guard against any edge-cases in future reordering.
288
314
  */
289
315
  private moveBlocksUp(sortedBlocks: Block[], baseInsertIndex: number): void {
290
- // Track how many blocks we've inserted to adjust the target index
316
+ const originalIndices = new Map<Block, number>();
317
+
318
+ sortedBlocks.forEach(block => {
319
+ originalIndices.set(block, this.blockManager.getBlockIndex(block));
320
+ });
321
+
291
322
  sortedBlocks.forEach((block, index) => {
292
323
  const currentIndex = this.blockManager.getBlockIndex(block);
293
324
  const targetIndex = baseInsertIndex + index;
@@ -296,7 +327,15 @@ export class DragOperations {
296
327
  return;
297
328
  }
298
329
 
299
- this.blockManager.move(targetIndex, currentIndex, false);
330
+ this.blockManager.move(targetIndex, currentIndex, false, true);
331
+ });
332
+
333
+ sortedBlocks.forEach(block => {
334
+ block.call(BlockToolAPI.MOVED, {
335
+ fromIndex: originalIndices.get(block) ?? 0,
336
+ toIndex: this.blockManager.getBlockIndex(block),
337
+ isGroupMove: true,
338
+ });
300
339
  });
301
340
  }
302
341
  }
@@ -84,6 +84,42 @@ function isDefaultBlack(color: string): boolean {
84
84
  return normalized === 'rgb(0,0,0)' || normalized === '#000000';
85
85
  }
86
86
 
87
+ /**
88
+ * Compute the relative luminance of a CSS color value (rgb() or hex format).
89
+ * Returns a value in [0, 1], or -1 if the format is unrecognized.
90
+ * Uses simplified linear luminance (no gamma correction), adequate for
91
+ * threshold comparisons at this scale.
92
+ */
93
+ function computeRelativeLuminance(color: string): number {
94
+ const normalized = color.replace(/\s/g, '').toLowerCase();
95
+
96
+ const rgbMatch = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(normalized);
97
+
98
+ if (rgbMatch) {
99
+ const r = parseInt(rgbMatch[1], 10) / 255;
100
+ const g = parseInt(rgbMatch[2], 10) / 255;
101
+ const b = parseInt(rgbMatch[3], 10) / 255;
102
+
103
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
104
+ }
105
+
106
+ const hexMatch = /^#([0-9a-f]{6}|[0-9a-f]{3})$/.exec(normalized);
107
+
108
+ if (hexMatch) {
109
+ const hex = hexMatch[1];
110
+ const expand = hex.length === 3
111
+ ? [hex[0] + hex[0], hex[1] + hex[1], hex[2] + hex[2]]
112
+ : [hex.substring(0, 2), hex.substring(2, 4), hex.substring(4, 6)];
113
+ const r = parseInt(expand[0], 16) / 255;
114
+ const g = parseInt(expand[1], 16) / 255;
115
+ const b = parseInt(expand[2], 16) / 255;
116
+
117
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
118
+ }
119
+
120
+ return -1;
121
+ }
122
+
87
123
  /**
88
124
  * Check whether a CSS background-color value is the default white page background.
89
125
  * When the browser natively copies from a contenteditable, it adds computed styles
@@ -96,6 +132,35 @@ function isDefaultWhiteBackground(bgColor: string): boolean {
96
132
  return normalized === 'rgb(255,255,255)' || normalized === '#ffffff' || normalized === 'white';
97
133
  }
98
134
 
135
+ /**
136
+ * Check whether a CSS background-color value is a near-black (dark mode page) background.
137
+ * When the browser natively copies from a contenteditable in dark mode, it adds computed
138
+ * styles including the resolved dark page background (e.g. rgb(25, 25, 24) for Blok's
139
+ * #191918 dark background). These should not be treated as intentional marker formatting.
140
+ *
141
+ * Uses relative luminance < 0.12, which is below all Blok dark background presets
142
+ * (~18% minimum lightness for #2f2f2f) while catching typical dark page backgrounds.
143
+ */
144
+ function isDefaultDarkBackground(bgColor: string): boolean {
145
+ const luminance = computeRelativeLuminance(bgColor);
146
+
147
+ return luminance >= 0 && luminance < 0.12;
148
+ }
149
+
150
+ /**
151
+ * Check whether a CSS color value is a near-white (dark mode default text) color.
152
+ * When the browser natively copies from a contenteditable in dark mode, it includes
153
+ * the resolved light page text color (e.g. rgb(226, 224, 220) for Blok's #e2e0dc
154
+ * default text). These should not be treated as intentional marker formatting for
155
+ * non-Google-Docs content.
156
+ *
157
+ * Uses relative luminance > 0.75, which is above all Blok text presets while
158
+ * catching typical dark mode default text colors.
159
+ */
160
+ function isDefaultLightText(color: string): boolean {
161
+ return computeRelativeLuminance(color) > 0.75;
162
+ }
163
+
99
164
  /**
100
165
  * Optionally wrap innerHTML in a `<mark>` with mapped color styles.
101
166
  * Returns the original content unchanged when no color formatting is needed.
@@ -145,10 +210,12 @@ function convertSpanToSemanticHtml(span: Element, isGoogleDocs: boolean): string
145
210
  const color = colorMatch?.[1]?.trim();
146
211
  const bgColor = bgMatch?.[1]?.trim();
147
212
 
148
- const hasColor = color !== undefined && !isDefaultBlack(color);
213
+ const hasColor = isGoogleDocs
214
+ ? color !== undefined && !isDefaultBlack(color)
215
+ : color !== undefined && !isDefaultBlack(color) && !isDefaultLightText(color);
149
216
  const hasBgColor = isGoogleDocs
150
217
  ? bgColor !== undefined && bgColor !== 'transparent'
151
- : bgColor !== undefined && bgColor !== 'transparent' && !isDefaultWhiteBackground(bgColor);
218
+ : bgColor !== undefined && bgColor !== 'transparent' && !isDefaultWhiteBackground(bgColor) && !isDefaultDarkBackground(bgColor);
152
219
 
153
220
  if (!isBold && !isItalic && !hasColor && !hasBgColor) {
154
221
  return null;