@jackuait/blok 0.7.3-beta.4 → 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 (67) 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 +2 -2
  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/utils/popover/popover-item.d.ts +6 -0
  67. package/CHANGELOG.md +0 -119
@@ -127,9 +127,19 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
127
127
  }
128
128
 
129
129
  /**
130
- * Insert Blok JSON blocks.
131
- * After inserting all blocks, restores parent-child hierarchy using an ID mapping
132
- * (pasted blocks receive new IDs, so original IDs are mapped to new block instances).
130
+ * Insert Blok JSON blocks using a two-pass approach:
131
+ *
132
+ * Pass 1 child blocks (those whose parentId is within the pasted set) are
133
+ * inserted first. They receive new IDs, which are recorded in a map.
134
+ *
135
+ * Pass 2 — root/container blocks (e.g. tables) are inserted with their data
136
+ * remapped so that any old child-block ID references are replaced by the new
137
+ * IDs from Pass 1. This prevents container tools (TableCellBlocks) from
138
+ * resolving old IDs that still exist in the editor and stealing blocks from
139
+ * the original table.
140
+ *
141
+ * After both passes the parent-child hierarchy is re-established using the
142
+ * accumulated old→new ID mapping.
133
143
  */
134
144
  private insertBlokBlocks(
135
145
  blocks: BlokClipboardBlock[],
@@ -142,31 +152,70 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
142
152
  this.config.sanitizer
143
153
  );
144
154
 
155
+ // Capture replace intent before any insertions move the current block pointer.
156
+ const shouldReplaceFirst =
157
+ canReplace &&
158
+ Boolean(BlockManager.currentBlock?.tool.isDefault) &&
159
+ Boolean(BlockManager.currentBlock?.isEmpty);
160
+
161
+ // Set of old IDs present in this paste, used to identify parent-child pairs.
162
+ const pastedOldIds = new Set(blocks.map(b => b.id));
163
+
164
+ type Entry = { sanitized: (typeof sanitizedBlocks)[number]; original: BlokClipboardBlock };
165
+ const children: Entry[] = [];
166
+ const roots: Entry[] = [];
167
+
168
+ sanitizedBlocks.forEach((sanitizedBlock, i) => {
169
+ const original = blocks[i];
170
+
171
+ if (original === undefined) {
172
+ return;
173
+ }
174
+
175
+ const isChild =
176
+ original.parentId !== undefined &&
177
+ original.parentId !== null &&
178
+ pastedOldIds.has(original.parentId);
179
+
180
+ (isChild ? children : roots).push({ sanitized: sanitizedBlock, original });
181
+ });
182
+
145
183
  /**
146
184
  * Map from original (old) block ID to the newly inserted Block instance.
147
- * Used after insertion to re-establish parent-child relationships.
185
+ * Used to remap data and restore hierarchy after both passes.
148
186
  */
149
187
  const oldIdToEntry = new Map<string, { newBlock: Block; original: BlokClipboardBlock }>();
150
188
 
151
- sanitizedBlocks.forEach((sanitizedBlock, i) => {
152
- const { tool, data } = sanitizedBlock;
153
- const needToReplaceCurrentBlock = i === 0 &&
154
- canReplace &&
155
- Boolean(BlockManager.currentBlock?.tool.isDefault) &&
156
- Boolean(BlockManager.currentBlock?.isEmpty);
189
+ // Pass 1: insert children first so they exist with new IDs before the parent.
190
+ children.forEach(({ sanitized, original }) => {
191
+ const block = BlockManager.insert({ tool: sanitized.tool, data: sanitized.data });
157
192
 
158
- const block = BlockManager.insert({
159
- tool,
160
- data,
161
- replace: needToReplaceCurrentBlock,
162
- });
193
+ oldIdToEntry.set(original.id, { newBlock: block, original });
194
+ Caret.setToBlock(block, Caret.positions.END);
195
+ });
163
196
 
164
- const originalBlock = blocks[i];
197
+ // Build old→new string map for remapping ID references inside parent data.
198
+ const oldIdToNewId = new Map<string, string>();
165
199
 
166
- if (originalBlock !== undefined) {
167
- oldIdToEntry.set(originalBlock.id, { newBlock: block, original: originalBlock });
168
- }
200
+ for (const [oldId, { newBlock }] of oldIdToEntry) {
201
+ oldIdToNewId.set(oldId, newBlock.id);
202
+ }
169
203
 
204
+ // Pass 2: insert root blocks with child IDs remapped in their data.
205
+ // Skip replace when children were pre-inserted to avoid replacing a
206
+ // just-inserted child paragraph rather than the original empty block.
207
+ roots.forEach(({ sanitized, original }, idx) => {
208
+ const remappedData = oldIdToNewId.size > 0
209
+ ? remapIds(sanitized.data, oldIdToNewId) as typeof sanitized.data
210
+ : sanitized.data;
211
+
212
+ const block = BlockManager.insert({
213
+ tool: sanitized.tool,
214
+ data: remappedData,
215
+ replace: idx === 0 && shouldReplaceFirst && children.length === 0,
216
+ });
217
+
218
+ oldIdToEntry.set(original.id, { newBlock: block, original });
170
219
  Caret.setToBlock(block, Caret.positions.END);
171
220
  });
172
221
 
@@ -193,3 +242,31 @@ export class BlokDataHandler extends BasePasteHandler implements PasteHandler {
193
242
  }
194
243
  }
195
244
  }
245
+
246
+ /**
247
+ * Recursively walks `value` and replaces any string found as a key in `idMap`
248
+ * with its mapped value. Used to remap old block IDs to new IDs within a
249
+ * tool's data object before insertion, so that container blocks (e.g. tables)
250
+ * reference the correct newly-inserted child block IDs.
251
+ */
252
+ function remapIds(value: unknown, idMap: Map<string, string>): unknown {
253
+ if (typeof value === 'string') {
254
+ return idMap.get(value) ?? value;
255
+ }
256
+
257
+ if (Array.isArray(value)) {
258
+ return value.map(item => remapIds(item, idMap));
259
+ }
260
+
261
+ if (value !== null && typeof value === 'object') {
262
+ const result: Record<string, unknown> = {};
263
+
264
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
265
+ result[k] = remapIds(v, idMap);
266
+ }
267
+
268
+ return result;
269
+ }
270
+
271
+ return value;
272
+ }
@@ -10,6 +10,7 @@ import {
10
10
  shouldExpandToHierarchical,
11
11
  type DataFormatAnalysis,
12
12
  } from '../utils/data-model-transform';
13
+ import { migrateMarkColors } from '../utils/color-migration';
13
14
 
14
15
  /**
15
16
  * Module that responsible for rendering Blocks on blok initialization
@@ -157,6 +158,7 @@ export class Renderer extends Module {
157
158
  * Insert batch of Blocks
158
159
  */
159
160
  BlockManager.insertMany(blocks);
161
+ migrateMarkColors(this.Blok.UI.nodes.redactor);
160
162
  }
161
163
 
162
164
  /**
@@ -396,7 +396,7 @@ export class BlockSettings extends Module<BlockSettingsNodes> {
396
396
  title: this.Blok.I18n.t('popover.convertTo'),
397
397
  children: {
398
398
  items: convertToItems,
399
- width: '200px',
399
+ minWidth: '200px',
400
400
  },
401
401
  });
402
402
  items.push({
@@ -140,6 +140,7 @@ export class Toolbar extends Module<ToolbarNodes> {
140
140
  {
141
141
  getToolboxOpened: () => this.toolbox.opened ?? false,
142
142
  openToolbox: () => this.toolbox.open(),
143
+ openToolboxWithoutSlash: () => this.toolbox.openWithoutSlash(),
143
144
  closeToolbox: () => this.toolbox.close(),
144
145
  moveAndOpenToolbar: (block, target) => this.moveAndOpen(block, target),
145
146
  }
@@ -202,6 +203,7 @@ export class Toolbar extends Module<ToolbarNodes> {
202
203
  opened: boolean | undefined; // undefined is for the case when Toolbox is not initialized yet
203
204
  close: () => void;
204
205
  open: () => void;
206
+ openWithoutSlash: () => void;
205
207
  toggle: () => void;
206
208
  hasFocus: () => boolean | undefined;
207
209
  } {
@@ -210,6 +212,25 @@ export class Toolbar extends Module<ToolbarNodes> {
210
212
  close: () => {
211
213
  this.toolboxInstance?.close();
212
214
  },
215
+ openWithoutSlash: () => {
216
+ if (this.toolboxInstance === null) {
217
+ log('toolbox.openWithoutSlash() called before initialization is finished', 'warn');
218
+
219
+ return;
220
+ }
221
+
222
+ if (this.hoveredBlock && !this.hoveredBlockIsFromTableCell) {
223
+ const currentBlock = this.Blok.BlockManager.currentBlock;
224
+ const isCurrentBlockInsideTableCell = currentBlock !== undefined
225
+ && currentBlock.holder.closest('[data-blok-table-cell-blocks]') !== null;
226
+
227
+ if (!isCurrentBlockInsideTableCell) {
228
+ this.Blok.BlockManager.currentBlock = this.hoveredBlock;
229
+ }
230
+ }
231
+
232
+ this.toolboxInstance.open(false);
233
+ },
213
234
  open: () => {
214
235
  /**
215
236
  * If Toolbox is not initialized yet, do nothing
@@ -27,10 +27,15 @@ export class PlusButtonHandler {
27
27
  private getToolboxOpened: () => boolean;
28
28
 
29
29
  /**
30
- * Callback to open the toolbox
30
+ * Callback to open the toolbox in slash-search mode
31
31
  */
32
32
  private openToolbox: () => void;
33
33
 
34
+ /**
35
+ * Callback to open the toolbox in no-slash mode (used when clicking the plus button)
36
+ */
37
+ private openToolboxWithoutSlash: () => void;
38
+
34
39
  /**
35
40
  * Callback to close the toolbox
36
41
  */
@@ -50,6 +55,7 @@ export class PlusButtonHandler {
50
55
  callbacks: {
51
56
  getToolboxOpened: () => boolean;
52
57
  openToolbox: () => void;
58
+ openToolboxWithoutSlash: () => void;
53
59
  closeToolbox: () => void;
54
60
  moveAndOpenToolbar: (block?: Block | null, target?: Element | null) => void;
55
61
  }
@@ -57,6 +63,7 @@ export class PlusButtonHandler {
57
63
  this.getBlok = getBlok;
58
64
  this.getToolboxOpened = callbacks.getToolboxOpened;
59
65
  this.openToolbox = callbacks.openToolbox;
66
+ this.openToolboxWithoutSlash = callbacks.openToolboxWithoutSlash;
60
67
  this.closeToolbox = callbacks.closeToolbox;
61
68
  this.moveAndOpenToolbar = callbacks.moveAndOpenToolbar;
62
69
  }
@@ -196,14 +203,17 @@ export class PlusButtonHandler {
196
203
  hoveredBlock?.holder.after(targetBlock.holder);
197
204
  }
198
205
 
199
- // Insert "/" or position caret after existing one
206
+ // Position caret and open toolbox
200
207
  if (startsWithSlash) {
208
+ // Block already has "/" - keep slash-search mode, position after the slash
201
209
  Caret.setToBlock(targetBlock, Caret.positions.DEFAULT, 1);
210
+ this.moveAndOpenToolbar(targetBlock);
211
+ this.openToolbox();
202
212
  } else {
213
+ // New empty block - open toolbox directly without inserting "/"
203
214
  Caret.setToBlock(targetBlock, Caret.positions.START);
204
- Caret.insertContentAtCaretPosition('/');
215
+ this.moveAndOpenToolbar(targetBlock);
216
+ this.openToolboxWithoutSlash();
205
217
  }
206
- this.moveAndOpenToolbar(targetBlock);
207
- this.openToolbox();
208
218
  }
209
219
  }
@@ -119,18 +119,17 @@ export class FakeBackgroundManager {
119
119
  * Unwraps the highlight spans and restores the selection
120
120
  */
121
121
  removeFakeBackground(): void {
122
- // Always clean up any orphaned fake background elements in the DOM
123
- // This handles cleanup after undo/redo operations that may restore fake background elements
124
- this.removeOrphanedFakeBackgroundElements();
125
-
126
- if (!this.selectionUtils.isFakeBackgroundEnabled) {
127
- return;
122
+ if (this.selectionUtils.isFakeBackgroundEnabled) {
123
+ // Remove highlight spans first while they still exist in the DOM,
124
+ // so that removeHighlightSpans() can reconstruct savedSelectionRange
125
+ // as a text-node-based range before the spans are gone.
126
+ this.removeHighlightSpans();
127
+ this.selectionUtils.isFakeBackgroundEnabled = false;
128
128
  }
129
129
 
130
- // Remove the highlight spans
131
- this.removeHighlightSpans();
132
-
133
- this.selectionUtils.isFakeBackgroundEnabled = false;
130
+ // Clean up any remaining/orphaned fake background elements
131
+ // (handles undo/redo restoring spans, backwards compat, etc.)
132
+ this.removeOrphanedFakeBackgroundElements();
134
133
  }
135
134
 
136
135
  /**
@@ -1,7 +1,26 @@
1
1
  import type { I18n } from '../../../types/api';
2
2
  import { parseColor } from '../utils/color-mapping';
3
+ import { onHover } from '../utils/tooltip';
3
4
  import { twMerge } from '../utils/tw';
4
- import { COLOR_PRESETS } from './color-presets';
5
+ import { COLOR_PRESETS, COLOR_PRESETS_DARK } from './color-presets';
6
+
7
+ /**
8
+ * Returns the appropriate preset array for the current theme.
9
+ * Checks the data-blok-theme attribute first (explicit override),
10
+ * then falls back to the prefers-color-scheme media query.
11
+ */
12
+ function getActivePresets(): typeof COLOR_PRESETS {
13
+ const theme = document.documentElement.getAttribute('data-blok-theme');
14
+
15
+ if (theme === 'dark') return COLOR_PRESETS_DARK;
16
+ if (theme === 'light') return COLOR_PRESETS;
17
+
18
+ if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
19
+ return COLOR_PRESETS_DARK;
20
+ }
21
+
22
+ return COLOR_PRESETS;
23
+ }
5
24
 
6
25
  /**
7
26
  * Compare two CSS color strings for equality by their parsed RGB tuples.
@@ -23,7 +42,7 @@ function colorsEqual(a: string, b: string): boolean {
23
42
  }
24
43
 
25
44
  /**
26
- * Describes one tab in the color picker (e.g. "Text" or "Background")
45
+ * Describes one section in the color picker (e.g. "Text" or "Background")
27
46
  */
28
47
  export interface ColorPickerMode {
29
48
  key: string;
@@ -37,7 +56,6 @@ export interface ColorPickerMode {
37
56
  export interface ColorPickerOptions {
38
57
  i18n: I18n;
39
58
  modes: [ColorPickerMode, ColorPickerMode];
40
- defaultModeIndex?: number;
41
59
  testIdPrefix: string;
42
60
  onColorSelect: (color: string | null, modeKey: string) => void;
43
61
  }
@@ -49,163 +67,158 @@ export interface ColorPickerHandle {
49
67
  element: HTMLDivElement;
50
68
  /**
51
69
  * Set the currently active color for visual indication on the matching swatch.
52
- * Pass null to clear any active indicator.
70
+ * Pass null to clear any active indicator for that section.
53
71
  * @param color - CSS color value or null to clear
54
- * @param modeKey - The mode key (e.g. 'color', 'background-color') to match the correct preset field
72
+ * @param modeKey - The mode key (e.g. 'color', 'background-color') to match the correct section
55
73
  */
56
74
  setActiveColor: (color: string | null, modeKey: string) => void;
57
75
  /**
58
- * Reset the picker state (tab index) back to defaultModeIndex.
59
- * Call this when the picker is reopened to ensure consistent initial state.
76
+ * Reset the picker state back to defaults (clear all active colors).
60
77
  */
61
78
  reset: () => void;
62
79
  }
63
80
 
64
81
  /**
65
- * Base Tailwind classes shared by tab buttons
66
- */
67
- const TAB_BASE_CLASSES = 'flex-1 py-1.5 text-xs text-center rounded-md cursor-pointer border-none outline-hidden transition-colors';
68
-
69
- /**
70
- * Neutral background for text-mode swatches so they render as visible buttons
82
+ * Neutral background for text-mode swatches so they render as visible buttons.
83
+ * Uses a CSS variable so it adapts to dark mode.
71
84
  */
72
- const SWATCH_NEUTRAL_BG = '#f7f7f5';
85
+ const SWATCH_NEUTRAL_BG = 'var(--blok-swatch-neutral-bg)';
73
86
 
74
87
  /**
75
- * Creates a color picker element with two tabs (e.g. Text / Background),
76
- * a 5-column swatch grid, and a "Default" reset button.
88
+ * Creates a color picker element with two always-visible sections (e.g. Text / Background),
89
+ * each containing a 5-column swatch grid with a "Default" reset swatch at position 0.
77
90
  *
78
91
  * Shared between the marker inline tool and the table cell color popover.
79
92
  */
80
93
  export function createColorPicker(options: ColorPickerOptions): ColorPickerHandle {
81
94
  const { i18n, modes, testIdPrefix, onColorSelect } = options;
82
- const defaultModeIndex = options.defaultModeIndex ?? 0;
83
- const state = { modeIndex: defaultModeIndex, activeColor: null as string | null };
95
+ const state = {
96
+ activeColors: Object.fromEntries(modes.map((m) => [m.key, null])) as Record<string, string | null>,
97
+ };
84
98
 
85
99
  const wrapper = document.createElement('div');
86
100
 
87
101
  wrapper.setAttribute('data-blok-testid', `${testIdPrefix}-picker`);
88
- wrapper.className = 'flex flex-col gap-2 p-2';
102
+ wrapper.className = 'flex flex-col gap-3 p-2';
89
103
 
90
104
  /**
91
- * Tab row
105
+ * One grid element per section, stored so we can re-render independently.
92
106
  */
93
- const tabRow = document.createElement('div');
107
+ const sectionGrids: HTMLDivElement[] = [];
94
108
 
95
- tabRow.className = 'flex gap-0.5 mb-0.5';
109
+ /**
110
+ * Build sections once; re-render only the grids on state changes.
111
+ */
112
+ modes.forEach((mode) => {
113
+ const section = document.createElement('div');
96
114
 
97
- const tabButtons: HTMLButtonElement[] = [];
115
+ section.setAttribute('data-blok-testid', `${testIdPrefix}-section-${mode.key}`);
116
+ section.className = 'flex flex-col gap-1';
98
117
 
99
- modes.forEach((mode, modeIndex) => {
100
- const tab = document.createElement('button');
118
+ const title = document.createElement('div');
101
119
 
102
- tab.setAttribute('data-blok-testid', `${testIdPrefix}-tab-${mode.key}`);
103
- tab.textContent = i18n.t(mode.labelKey);
104
- tab.addEventListener('click', () => {
105
- state.modeIndex = modeIndex;
106
- updateTabs();
107
- renderSwatches();
108
- });
109
- tabButtons.push(tab);
110
- tabRow.appendChild(tab);
111
- });
120
+ title.className = 'text-xs font-medium text-text-primary/60 px-0.5';
121
+ title.textContent = i18n.t(mode.labelKey);
112
122
 
113
- const updateTabs = (): void => {
114
- for (const [index, button] of tabButtons.entries()) {
115
- button.className = twMerge(
116
- TAB_BASE_CLASSES,
117
- index === state.modeIndex ? 'bg-item-hover-bg font-medium' : 'bg-transparent hover:bg-item-hover-bg/50'
118
- );
119
- }
120
- };
123
+ const grid = document.createElement('div');
124
+
125
+ grid.className = 'grid gap-1';
126
+ grid.style.gridTemplateColumns = 'repeat(5, 2.25rem)';
127
+ sectionGrids.push(grid);
128
+
129
+ section.appendChild(title);
130
+ section.appendChild(grid);
131
+ wrapper.appendChild(section);
132
+ });
121
133
 
122
134
  /**
123
- * Color grid
135
+ * Render the swatches for one section.
124
136
  */
125
- const grid = document.createElement('div');
126
-
127
- grid.setAttribute('data-blok-testid', `${testIdPrefix}-grid`);
128
- grid.className = 'grid grid-cols-5 gap-1.5';
137
+ const renderSection = (modeIndex: number): void => {
138
+ const grid = sectionGrids[modeIndex];
139
+ const mode = modes[modeIndex];
140
+ const presets = getActivePresets();
129
141
 
130
- const renderSwatches = (): void => {
131
142
  grid.innerHTML = '';
132
143
 
133
- const currentMode = modes[state.modeIndex];
144
+ const activeColorForSection = state.activeColors[mode.key];
145
+
146
+ // Default swatch (first position) — clears the active color for this section
147
+ const defaultSwatch = document.createElement('button');
148
+ const isDefaultActive = activeColorForSection === null;
149
+
150
+ defaultSwatch.setAttribute('data-blok-testid', `${testIdPrefix}-swatch-${mode.key}-default`);
151
+ defaultSwatch.className = twMerge(
152
+ 'w-9 h-9 rounded-md cursor-pointer border-none outline-hidden',
153
+ 'flex items-center justify-center text-sm font-semibold',
154
+ 'transition-[box-shadow,transform] ring-inset hover:ring-2 hover:ring-swatch-ring-hover active:scale-90',
155
+ isDefaultActive && 'ring-2 ring-swatch-ring-hover'
156
+ );
157
+ defaultSwatch.textContent = mode.presetField === 'text' ? 'A' : '';
158
+
159
+ if (mode.presetField === 'text') {
160
+ defaultSwatch.style.color = 'var(--blok-text-primary)';
161
+ defaultSwatch.style.backgroundColor = SWATCH_NEUTRAL_BG;
162
+ } else {
163
+ defaultSwatch.style.backgroundColor = SWATCH_NEUTRAL_BG;
164
+ }
165
+ defaultSwatch.addEventListener('click', () => {
166
+ onColorSelect(null, mode.key);
167
+ });
168
+ onHover(defaultSwatch, `${i18n.t('tools.marker.default')} ${i18n.t(mode.labelKey).toLowerCase()}`, { placement: 'top' });
169
+ grid.appendChild(defaultSwatch);
134
170
 
135
- for (const preset of COLOR_PRESETS) {
171
+ for (const preset of presets) {
136
172
  const swatch = document.createElement('button');
137
- const swatchColor = currentMode.presetField === 'text' ? preset.text : preset.bg;
138
- const isActive = state.activeColor !== null && colorsEqual(swatchColor, state.activeColor);
173
+ const swatchColor = mode.presetField === 'text' ? preset.text : preset.bg;
174
+ const isActive = activeColorForSection !== null && colorsEqual(swatchColor, activeColorForSection);
139
175
 
140
- swatch.setAttribute('data-blok-testid', `${testIdPrefix}-swatch-${preset.name}`);
176
+ swatch.setAttribute('data-blok-testid', `${testIdPrefix}-swatch-${mode.key}-${preset.name}`);
141
177
  swatch.className = twMerge(
142
- 'w-8 h-8 rounded-md cursor-pointer border-none outline-hidden',
178
+ 'w-9 h-9 rounded-md cursor-pointer border-none outline-hidden',
143
179
  'flex items-center justify-center text-sm font-semibold',
144
- 'transition-shadow ring-inset hover:ring-2 hover:ring-black/10',
145
- isActive && 'ring-2 ring-black/30'
180
+ 'transition-[box-shadow,transform] ring-inset hover:ring-2 hover:ring-swatch-ring-hover active:scale-90',
181
+ isActive && 'ring-2 ring-swatch-ring-hover'
146
182
  );
147
- swatch.textContent = 'A';
183
+ swatch.textContent = mode.presetField === 'text' ? 'A' : '';
148
184
 
149
- if (currentMode.presetField === 'text') {
185
+ if (mode.presetField === 'text') {
150
186
  swatch.style.color = preset.text;
151
187
  swatch.style.backgroundColor = SWATCH_NEUTRAL_BG;
152
188
  } else {
153
- swatch.style.color = '';
189
+ swatch.style.color = presets === COLOR_PRESETS_DARK ? preset.text : '#37352f';
154
190
  swatch.style.backgroundColor = preset.bg;
155
191
  }
156
192
 
157
193
  swatch.addEventListener('click', () => {
158
- onColorSelect(swatchColor, currentMode.key);
194
+ onColorSelect(swatchColor, mode.key);
159
195
  });
196
+ onHover(swatch, `${i18n.t('tools.colorPicker.color.' + preset.name)} ${i18n.t(mode.labelKey).toLowerCase()}`, { placement: 'top' });
160
197
  grid.appendChild(swatch);
161
198
  }
162
199
  };
163
200
 
164
- /**
165
- * Default button
166
- */
167
- const defaultBtn = document.createElement('button');
168
-
169
- defaultBtn.setAttribute('data-blok-testid', `${testIdPrefix}-default-btn`);
170
- defaultBtn.className = twMerge(
171
- 'w-full py-1.5 text-xs text-center rounded-md cursor-pointer',
172
- 'bg-transparent border-none outline-hidden hover:bg-item-hover-bg',
173
- 'mt-0.5 transition-colors'
174
- );
175
- defaultBtn.textContent = i18n.t('tools.marker.default');
176
- defaultBtn.addEventListener('click', () => {
177
- onColorSelect(null, modes[state.modeIndex].key);
178
- });
179
-
180
- /**
181
- * Assemble
182
- */
183
- updateTabs();
184
- renderSwatches();
201
+ const renderAll = (): void => {
202
+ modes.forEach((_, i) => renderSection(i));
203
+ };
185
204
 
186
- wrapper.appendChild(tabRow);
187
- wrapper.appendChild(grid);
188
- wrapper.appendChild(defaultBtn);
205
+ renderAll();
189
206
 
190
207
  return {
191
208
  element: wrapper,
192
209
  setActiveColor: (color: string | null, modeKey: string) => {
193
- state.activeColor = color;
194
-
195
210
  const matchingIndex = modes.findIndex((m) => m.key === modeKey);
196
211
 
197
212
  if (matchingIndex !== -1) {
198
- state.modeIndex = matchingIndex;
199
- updateTabs();
213
+ state.activeColors[modeKey] = color;
214
+ renderSection(matchingIndex);
200
215
  }
201
-
202
- renderSwatches();
203
216
  },
204
217
  reset: () => {
205
- state.modeIndex = defaultModeIndex;
206
- state.activeColor = null;
207
- updateTabs();
208
- renderSwatches();
218
+ for (const mode of modes) {
219
+ state.activeColors[mode.key] = null;
220
+ }
221
+ renderAll();
209
222
  },
210
223
  };
211
224
  }
@@ -8,7 +8,7 @@ export interface ColorPreset {
8
8
  }
9
9
 
10
10
  /**
11
- * Ten Notion-style color presets.
11
+ * Ten Notion-style color presets for light mode.
12
12
  * `text` is used for foreground (text-color mode), `bg` for background swatches.
13
13
  */
14
14
  export const COLOR_PRESETS: ColorPreset[] = [
@@ -17,9 +17,37 @@ export const COLOR_PRESETS: ColorPreset[] = [
17
17
  { name: 'orange', text: '#d9730d', bg: '#fbecdd' },
18
18
  { name: 'yellow', text: '#cb9b00', bg: '#fbf3db' },
19
19
  { name: 'green', text: '#448361', bg: '#edf3ec' },
20
- { name: 'teal', text: '#2b9a8f', bg: '#e4f5f3' },
21
20
  { name: 'blue', text: '#337ea9', bg: '#e7f3f8' },
22
21
  { name: 'purple', text: '#9065b0', bg: '#f6f3f9' },
23
22
  { name: 'pink', text: '#c14c8a', bg: '#f9f0f5' },
24
23
  { name: 'red', text: '#d44c47', bg: '#fdebec' },
25
24
  ];
25
+
26
+ /**
27
+ * Dark-mode adapted presets. Text colors are lightened for readability on dark
28
+ * swatch backgrounds; background colors are deep/muted to integrate with dark UI.
29
+ * All pairs achieve at least 3.8:1 WCAG contrast. Backgrounds are equalized at
30
+ * ~L19% HSL with increased saturation so each hue family is clearly identifiable.
31
+ */
32
+ export const COLOR_PRESETS_DARK: ColorPreset[] = [
33
+ { name: 'gray', text: '#9b9b9b', bg: '#2f2f2f' },
34
+ { name: 'brown', text: '#c59177', bg: '#452a1c' },
35
+ { name: 'orange', text: '#dc8c47', bg: '#4d2f14' },
36
+ { name: 'yellow', text: '#d4ab49', bg: '#544012' },
37
+ { name: 'green', text: '#5db184', bg: '#1e432f' },
38
+ { name: 'blue', text: '#5c9fcc', bg: '#123a54' },
39
+ { name: 'purple', text: '#a67dca', bg: '#341d49' },
40
+ { name: 'pink', text: '#d45e99', bg: '#4b1b33' },
41
+ { name: 'red', text: '#dd5e5a', bg: '#4e1a18' },
42
+ ];
43
+
44
+ /**
45
+ * Construct a CSS custom property reference for a named preset color.
46
+ *
47
+ * @param name - The color preset name (e.g. 'red', 'blue')
48
+ * @param mode - 'text' for foreground, 'bg' for background
49
+ * @returns CSS var reference, e.g. `var(--blok-color-red-text)`
50
+ */
51
+ export function colorVarName(name: string, mode: 'text' | 'bg'): string {
52
+ return `var(--blok-color-${name}-${mode})`;
53
+ }