@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
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { wn as e } from "./chunks/constants-C_H9o9Ao.mjs";
2
- import { n as t, t as n } from "./chunks/blok-CdxHhr5i.mjs";
1
+ import { An as e } from "./chunks/constants-WhLyFkza.mjs";
2
+ import { n as t, t as n } from "./chunks/blok-BmlbETK7.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-CyPxu8-u.mjs";
4
- import { a as i, d as a, i as o, l as s, n as c, o as l, r as u, s as d, t as f, u as p } from "./chunks/tools-B0YXCZFW.mjs";
4
+ import { a as i, d as a, i as o, l as s, n as c, o as l, r as u, s as d, t as f, u as p } from "./chunks/tools-BCb5bMO3.mjs";
5
5
  //#region src/full.ts
6
6
  var m = {
7
7
  paragraph: {
package/dist/locales.mjs CHANGED
@@ -25,6 +25,15 @@ var e = {
25
25
  "tools.marker.textColor": "Text",
26
26
  "tools.marker.background": "Background",
27
27
  "tools.marker.default": "Default",
28
+ "tools.colorPicker.color.gray": "Gray",
29
+ "tools.colorPicker.color.brown": "Brown",
30
+ "tools.colorPicker.color.orange": "Orange",
31
+ "tools.colorPicker.color.yellow": "Yellow",
32
+ "tools.colorPicker.color.green": "Green",
33
+ "tools.colorPicker.color.blue": "Blue",
34
+ "tools.colorPicker.color.purple": "Purple",
35
+ "tools.colorPicker.color.pink": "Pink",
36
+ "tools.colorPicker.color.red": "Red",
28
37
  "tools.stub.error": "Error",
29
38
  "tools.stub.blockCannotBeDisplayed": "This block cannot be displayed",
30
39
  "tools.header.heading1": "Heading 1",
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import "./chunks/constants-C_H9o9Ao.mjs";
2
- import { r as e, t } from "./chunks/blok-CdxHhr5i.mjs";
1
+ import "./chunks/constants-WhLyFkza.mjs";
2
+ import { r as e, t } from "./chunks/blok-BmlbETK7.mjs";
3
3
  import { t as n } from "./chunks/objectSpread2-CyPxu8-u.mjs";
4
4
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
5
5
  import { jsx as c } from "react/jsx-runtime";
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-C_H9o9Ao.mjs";
2
- import { a as t, c as n, d as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./chunks/tools-B0YXCZFW.mjs";
1
+ import { m as e } from "./chunks/constants-WhLyFkza.mjs";
2
+ import { a as t, c as n, d as r, i, l as a, n as o, o as s, r as c, s as l, t as u, u as d } from "./chunks/tools-BCb5bMO3.mjs";
3
3
  export { s as Bold, e as Convert, d as Header, t as Italic, i as Link, a as List, c as Marker, r as Paragraph, n as Table, l as Toggle, u as defaultBlockTools, o as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.7.3-beta.4",
3
+ "version": "0.7.3",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "types": "./types/index.d.ts",
@@ -200,4 +200,4 @@
200
200
  "optional": true
201
201
  }
202
202
  }
203
- }
203
+ }
@@ -10,7 +10,7 @@ export class StyleManager {
10
10
  * Tailwind styles for the Block elements
11
11
  */
12
12
  private static readonly styles = {
13
- wrapper: 'relative opacity-100 my-[-0.5em] py-[0.5em] first:mt-0 last:pb-0 last:mb-0 [&_a]:cursor-pointer [&_a]:underline [&_a]:text-link [&_b]:font-bold [&_i]:italic',
13
+ wrapper: 'relative opacity-100 first:mt-0 last:pb-0 last:mb-0 [&_a]:cursor-pointer [&_a]:underline [&_a]:text-link [&_b]:font-bold [&_i]:italic',
14
14
  content: 'relative mx-auto transition-colors duration-150 ease-out max-w-blok-content',
15
15
  contentSelected: 'bg-selection rounded-[4px] **:[[contenteditable]]:select-none [&_img]:opacity-55 **:data-[blok-tool=stub]:opacity-55',
16
16
  contentStretched: 'max-w-none',
@@ -116,7 +116,7 @@ export class Blocks {
116
116
  * @param {number} fromIndex - block to move
117
117
  * @param {boolean} skipDOM - if true, do not manipulate DOM (useful when SortableJS already did it)
118
118
  */
119
- public move(toIndex: number, fromIndex: number, skipDOM = false): void {
119
+ public move(toIndex: number, fromIndex: number, skipDOM = false, skipMovedHook = false): void {
120
120
  /**
121
121
  * cut out the block, move the DOM element and insert at the desired index
122
122
  * again (the shifting within the blocks array will happen automatically).
@@ -132,10 +132,7 @@ export class Blocks {
132
132
  block.holder.parentElement !== this.workingArea;
133
133
 
134
134
  if (!skipDOM && !isNested) {
135
- const previousBlockIndex = Math.max(0, toIndex - 1);
136
- const position: InsertPosition = toIndex > 0 ? 'afterend' : 'beforebegin';
137
-
138
- this.moveHolderInDOM(block, this.blocks[previousBlockIndex].holder, position);
135
+ this.moveHolderInDOM(block, toIndex);
139
136
  }
140
137
 
141
138
  // move in array
@@ -145,11 +142,13 @@ export class Blocks {
145
142
  // immediately follow the moved block in the flat array, matching DOM nesting.
146
143
  this.resortNestedBlocks(block, this.blocks.indexOf(block));
147
144
 
148
- // invoke hook
149
- block.call(BlockToolAPI.MOVED, {
150
- fromIndex,
151
- toIndex,
152
- });
145
+ // invoke hook (skipped during batch moves — caller re-triggers after all blocks land)
146
+ if (!skipMovedHook) {
147
+ block.call(BlockToolAPI.MOVED, {
148
+ fromIndex,
149
+ toIndex,
150
+ });
151
+ }
153
152
  }
154
153
 
155
154
  /**
@@ -412,52 +411,36 @@ export class Blocks {
412
411
  return;
413
412
  }
414
413
 
415
- const referenceNode = this.findWorkingAreaChild(target.holder);
416
-
417
- if (referenceNode !== null) {
418
- referenceNode.insertAdjacentElement(position, block.holder);
419
- } else {
420
- this.workingArea.appendChild(block.holder);
421
- }
422
-
414
+ target.holder.insertAdjacentElement(position, block.holder);
423
415
  block.call(BlockToolAPI.RENDERED);
424
416
  }
425
417
 
426
418
  /**
427
- * Walk from an element up to find the ancestor that is a direct child of workingArea.
428
- * If the element itself is a direct child, returns the element.
429
- * Returns null if the element is not inside workingArea.
430
- *
431
- * @param element - Starting element to walk up from
432
- * @returns Direct child of workingArea, or null
433
- */
434
- /**
435
- * Move a block's holder in the DOM relative to a reference element.
436
- * If the reference is nested inside a container (e.g. a table cell),
437
- * walks up to find the workingArea-level ancestor and inserts relative to that.
419
+ * Move a block's holder in the DOM to the position indicated by toIndex in the
420
+ * post-splice flat array. Inserts directly before the next block's holder
421
+ * (without walking up to the workingArea root), so nested blocks are handled
422
+ * correctly — the moved block lands at the exact DOM position, not after the
423
+ * root-level ancestor of a nested reference block.
438
424
  *
439
425
  * @param block - Block whose holder to move
440
- * @param referenceHolder - The reference element to position relative to
441
- * @param position - Where to insert relative to the reference
426
+ * @param toIndex - Target index in the post-splice blocks array
442
427
  */
443
- private moveHolderInDOM(block: Block, referenceHolder: Element, position: InsertPosition): void {
444
- const referenceNode = this.findWorkingAreaChild(referenceHolder);
428
+ private moveHolderInDOM(block: Block, toIndex: number): void {
429
+ const nextBlock = this.blocks[toIndex];
445
430
 
446
- if (referenceNode === block.holder) {
447
- /**
448
- * Self-reference: the resolved reference is the block being moved itself,
449
- * which happens when the previousBlock's holder is nested inside block.holder.
450
- * Use the next workingArea sibling to avoid undefined behavior.
451
- */
431
+ if (nextBlock === undefined) {
432
+ this.workingArea.appendChild(block.holder);
433
+ } else if (block.holder.contains(nextBlock.holder)) {
434
+ // Self-reference: next block is nested inside the block being moved
435
+ // (e.g. moving a toggle forward; blocks[toIndex] is one of its children).
436
+ // Use the next workingArea sibling to avoid undefined DOM behavior.
452
437
  const nextSibling = block.holder.nextElementSibling;
453
438
 
454
- if (nextSibling) {
439
+ if (nextSibling !== null) {
455
440
  nextSibling.insertAdjacentElement('beforebegin', block.holder);
456
441
  }
457
- } else if (referenceNode !== null) {
458
- referenceNode.insertAdjacentElement(position, block.holder);
459
442
  } else {
460
- this.workingArea.appendChild(block.holder);
443
+ nextBlock.holder.insertAdjacentElement('beforebegin', block.holder);
461
444
  }
462
445
 
463
446
  block.call(BlockToolAPI.RENDERED);
@@ -495,15 +478,4 @@ export class Blocks {
495
478
  this.blocks.splice(newIdx + 1, 0, ...nested);
496
479
  }
497
480
 
498
- private findWorkingAreaChild(element: Element): Element | null {
499
- if (element.parentElement === this.workingArea) {
500
- return element;
501
- }
502
-
503
- if (element.parentElement === null) {
504
- return null;
505
- }
506
-
507
- return this.findWorkingAreaChild(element.parentElement);
508
- }
509
481
  }
@@ -161,8 +161,6 @@ export const DATA_ATTR = {
161
161
  popoverItemNoHover: 'data-blok-popover-item-no-hover',
162
162
  /** Disable focus handling */
163
163
  popoverItemNoFocus: 'data-blok-popover-item-no-focus',
164
- /** Wobble animation */
165
- popoverItemWobble: 'data-blok-popover-item-wobble',
166
164
  /** Destructive action item (e.g. delete) */
167
165
  popoverItemDestructive: 'data-blok-popover-item-destructive',
168
166
  /** Separator item */
@@ -24,6 +24,15 @@
24
24
  "tools.marker.textColor": "Text",
25
25
  "tools.marker.background": "Background",
26
26
  "tools.marker.default": "Default",
27
+ "tools.colorPicker.color.gray": "Gray",
28
+ "tools.colorPicker.color.brown": "Brown",
29
+ "tools.colorPicker.color.orange": "Orange",
30
+ "tools.colorPicker.color.yellow": "Yellow",
31
+ "tools.colorPicker.color.green": "Green",
32
+ "tools.colorPicker.color.blue": "Blue",
33
+ "tools.colorPicker.color.purple": "Purple",
34
+ "tools.colorPicker.color.pink": "Pink",
35
+ "tools.colorPicker.color.red": "Red",
27
36
  "tools.stub.error": "Error",
28
37
  "tools.stub.blockCannotBeDisplayed": "This block cannot be displayed",
29
38
  "tools.header.heading1": "Heading 1",
@@ -9,7 +9,7 @@ export const IconCross = `
9
9
  export const IconBold = `
10
10
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
11
11
  <path
12
- d="M5.5 11h4.9231c.6528 0 1.2789-.3161 1.7405-.8787.4617-.56259.721-1.32565.721-2.1213 0-.79565-.2593-1.55871-.721-2.12132C11.702 5.31607 11.0759 5 10.4231 5H5.5v6Zm0 0h5.5385c.6528 0 1.2789.3161 1.7405.8787.4617.5626.721 1.3257.721 2.1213 0 .7956-.2593 1.5587-.721 2.1213-.4616.5626-1.0877.8787-1.7405.8787H5.5v-6Z"
12
+ d="M6 11h4.9231c.6528 0 1.2789-.3161 1.7405-.8787.4617-.56259.721-1.32565.721-2.1213 0-.79565-.2593-1.55871-.721-2.12132C12.202 5.31607 11.5759 5 10.9231 5H6v6Zm0 0h5.5385c.6528 0 1.2789.3161 1.7405.8787.4617.5626.721 1.3257.721 2.1213 0 .7956-.2593 1.5587-.721 2.1213-.4616.5626-1.0877.8787-1.7405.8787H6v-6Z"
13
13
  stroke="currentColor"
14
14
  stroke-width="1.25"
15
15
  stroke-linecap="round"
@@ -22,7 +22,7 @@ export const IconBold = `
22
22
  export const IconItalic = `
23
23
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
24
24
  <path
25
- d="M8.5 5h6m-10 12h6m1-12-4 12"
25
+ d="M8.5 4h6m-10 12h6m1-12-4 12"
26
26
  stroke="currentColor"
27
27
  stroke-width="1.25"
28
28
  stroke-linecap="round"
@@ -39,12 +39,12 @@ export const IconLink = `
39
39
  </svg>
40
40
  `;
41
41
 
42
- // Marker/Color icon (letter A with color bar)
42
+ // Marker/Color icon (letter A in rounded square)
43
43
  export const IconMarker = `
44
44
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
45
- <path d="M6.5 14L10 5l3.5 9" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
46
- <path d="M7.5 11.5h5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
47
- <rect x="4.5" y="16" width="11" height="1.5" rx="0.75" fill="currentColor"/>
45
+ <rect x="1.25" y="1.25" width="17.5" height="17.5" rx="4.5" stroke="currentColor" stroke-width="1.25"/>
46
+ <path d="M6.5 14.5L10 5.5l3.5 9" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
47
+ <path d="M8.5 11h3" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
48
48
  </svg>
49
49
  `;
50
50
 
@@ -386,3 +386,31 @@ export const IconToggleH3 = `
386
386
  <path d="M16 7l2.5 3-2.5 3" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
387
387
  </svg>
388
388
  `;
389
+
390
+ // Globe icon (URL link type)
391
+ export const IconGlobe = `
392
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
393
+ <circle cx="10" cy="10" r="6" stroke="currentColor" stroke-width="1.25"/>
394
+ <path d="M10 4C8 6 7.5 8 7.5 10C7.5 12 8 14 10 16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
395
+ <path d="M10 4C12 6 12.5 8 12.5 10C12.5 12 12 14 10 16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
396
+ <path d="M4 10H16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
397
+ </svg>
398
+ `;
399
+
400
+ // Mail icon (email link type)
401
+ export const IconMail = `
402
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
403
+ <rect x="3" y="5.5" width="14" height="9" rx="1.5" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
404
+ <path d="M3 8L10 10L17 8" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
405
+ </svg>
406
+ `;
407
+
408
+ // Hash icon (anchor link type)
409
+ export const IconHash = `
410
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
411
+ <path d="M7.5 4L7.5 16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
412
+ <path d="M12.5 4L12.5 16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
413
+ <path d="M4 8H16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
414
+ <path d="M4 12H16" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
415
+ </svg>
416
+ `;
@@ -6,12 +6,15 @@ import type {
6
6
  import type { Notifier, Toolbar, I18n, InlineToolbar } from '../../../types/api';
7
7
  import type { MenuConfig } from '../../../types/tools';
8
8
  import { DATA_ATTR, createSelector, INLINE_TOOLBAR_INTERFACE_VALUE } from '../constants';
9
- import { IconLink } from '../icons';
9
+ import { IconLink, IconGlobe, IconMail, IconHash } from '../icons';
10
10
  import { SelectionUtils } from '../selection/index';
11
11
  import { log } from '../utils';
12
12
  import { PopoverItemType } from '../utils/popover';
13
13
  import { twMerge } from '../utils/tw';
14
14
 
15
+ const SUGGESTION_ROW_VALID = 'flex items-center gap-2 w-full mt-0.5 px-1.5 py-1.5 rounded-md text-left cursor-pointer can-hover:hover:bg-item-hover-bg transition-colors';
16
+ const SUGGESTION_ROW_INVALID = 'flex items-center gap-2 w-full mt-0.5 px-1.5 py-1.5 rounded-md text-left pointer-events-none';
17
+
15
18
  /**
16
19
  * Link Tool
17
20
  *
@@ -54,7 +57,7 @@ export class LinkInlineTool implements InlineTool {
54
57
  /**
55
58
  * Tailwind classes for input
56
59
  */
57
- private readonly INPUT_BASE_CLASSES = 'hidden w-full m-0 px-2 py-1 text-sm leading-[22px] font-medium bg-item-hover-bg border border-link-input-border rounded-md outline-hidden box-border appearance-none font-[inherit] placeholder:text-gray-text mobile:text-[15px] mobile:font-medium';
60
+ private readonly INPUT_BASE_CLASSES = 'hidden w-[200px] m-0 px-2 py-1 text-sm leading-[22px] font-medium text-text-primary bg-item-hover-bg border border-link-input-border rounded-lg! outline-hidden box-border appearance-none font-[inherit] placeholder:text-gray-text mobile:text-[15px] mobile:font-medium';
58
61
 
59
62
  /**
60
63
  * Data attributes for e2e selectors
@@ -70,9 +73,13 @@ export class LinkInlineTool implements InlineTool {
70
73
  */
71
74
  private nodes: {
72
75
  input: HTMLInputElement | null;
76
+ inputWrapper: HTMLElement | null;
77
+ suggestion: HTMLElement | null;
73
78
  button: HTMLButtonElement | null;
74
79
  } = {
75
80
  input: null,
81
+ inputWrapper: null,
82
+ suggestion: null,
76
83
  button: null,
77
84
  };
78
85
 
@@ -121,6 +128,9 @@ export class LinkInlineTool implements InlineTool {
121
128
  this.i18n = api.i18n;
122
129
  this.selection = new SelectionUtils();
123
130
  this.nodes.input = this.createInput();
131
+ this.nodes.suggestion = this.createSuggestion();
132
+ this.nodes.inputWrapper = document.createElement('div');
133
+ this.nodes.inputWrapper.append(this.nodes.input, this.nodes.suggestion);
124
134
  }
125
135
 
126
136
  /**
@@ -133,12 +143,11 @@ export class LinkInlineTool implements InlineTool {
133
143
  isActive: () => !!this.selection.findParentTag('A'),
134
144
  children: {
135
145
  hideChevron: true,
136
- width: '200px',
137
146
  items: [
138
147
  {
139
148
  type: PopoverItemType.Html,
140
- // Input is created in constructor, so it's always available here
141
- element: this.nodes.input as HTMLInputElement,
149
+ // Wrapper contains the input and suggestion chip
150
+ element: this.nodes.inputWrapper as HTMLElement,
142
151
  },
143
152
  ],
144
153
  onOpen: () => {
@@ -167,10 +176,195 @@ export class LinkInlineTool implements InlineTool {
167
176
  this.enterPressed(event);
168
177
  }
169
178
  });
179
+ input.addEventListener('paste', () => {
180
+ requestAnimationFrame(() => {
181
+ this.updateSuggestion(input.value);
182
+ });
183
+ });
184
+ input.addEventListener('input', () => {
185
+ this.updateSuggestion(input.value);
186
+ });
170
187
 
171
188
  return input;
172
189
  }
173
190
 
191
+ /**
192
+ * Create the suggestion chip shown below the input when a URL is present
193
+ */
194
+ private createSuggestion(): HTMLElement {
195
+ const wrapper = document.createElement('div');
196
+
197
+ wrapper.className = 'hidden';
198
+ wrapper.setAttribute('data-link-suggestion', '');
199
+
200
+ const divider = document.createElement('div');
201
+
202
+ divider.className = 'mt-1 mb-0.5 h-px bg-link-input-border';
203
+
204
+ const row = document.createElement('button');
205
+
206
+ row.type = 'button';
207
+ row.className = SUGGESTION_ROW_VALID;
208
+ row.setAttribute('data-link-suggestion-row', '');
209
+
210
+ const iconEl = document.createElement('span');
211
+
212
+ iconEl.className = 'text-gray-text shrink-0 flex [&>svg]:size-7';
213
+ iconEl.setAttribute('data-link-suggestion-icon', '');
214
+
215
+ const textEl = document.createElement('span');
216
+
217
+ textEl.className = 'flex-1 min-w-0';
218
+
219
+ const urlEl = document.createElement('span');
220
+
221
+ urlEl.className = 'block text-xs font-medium text-text-primary truncate';
222
+ urlEl.setAttribute('data-link-suggestion-url', '');
223
+
224
+ const typeEl = document.createElement('span');
225
+
226
+ typeEl.className = 'block text-[10.5px] text-gray-text leading-tight mt-px';
227
+ typeEl.setAttribute('data-link-suggestion-type', '');
228
+
229
+ textEl.append(urlEl, typeEl);
230
+ row.append(iconEl, textEl);
231
+ wrapper.append(divider, row);
232
+
233
+ row.addEventListener('mousedown', (e) => e.preventDefault());
234
+ row.addEventListener('click', () => this.confirmLink());
235
+
236
+ return wrapper;
237
+ }
238
+
239
+ /**
240
+ * Update the suggestion chip content and visibility based on current input value
241
+ */
242
+ private updateSuggestion(value: string): void {
243
+ if (!this.nodes.suggestion) {
244
+ return;
245
+ }
246
+
247
+ const trimmed = value.trim();
248
+
249
+ if (!trimmed) {
250
+ this.nodes.suggestion.classList.add('hidden');
251
+
252
+ return;
253
+ }
254
+
255
+ const isComplete = this.isLinkComplete(trimmed);
256
+ const { icon, label } = this.getLinkTypeInfo(trimmed);
257
+ const iconEl = this.nodes.suggestion.querySelector<HTMLElement>('[data-link-suggestion-icon]');
258
+ const urlEl = this.nodes.suggestion.querySelector<HTMLElement>('[data-link-suggestion-url]');
259
+ const typeEl = this.nodes.suggestion.querySelector<HTMLElement>('[data-link-suggestion-type]');
260
+ const row = this.nodes.suggestion.querySelector<HTMLElement>('[data-link-suggestion-row]');
261
+
262
+ if (iconEl) {
263
+ iconEl.innerHTML = icon;
264
+ iconEl.className = `${isComplete ? 'text-gray-text' : 'text-gray-text opacity-40'} shrink-0 flex [&>svg]:size-7`;
265
+ }
266
+ if (urlEl) {
267
+ urlEl.textContent = trimmed;
268
+ urlEl.className = `block text-xs font-medium truncate ${isComplete ? 'text-text-primary' : 'text-gray-text'}`;
269
+ }
270
+ if (typeEl) {
271
+ typeEl.textContent = isComplete ? label : 'Keep typing to add a link';
272
+ typeEl.className = 'block text-[10.5px] text-gray-text leading-tight mt-px';
273
+ }
274
+ if (row) {
275
+ row.className = isComplete ? SUGGESTION_ROW_VALID : SUGGESTION_ROW_INVALID;
276
+ }
277
+
278
+ this.nodes.suggestion.classList.remove('hidden');
279
+ }
280
+
281
+ /**
282
+ * Return true if the URL is complete enough to confirm as a link.
283
+ *
284
+ * Rules by category:
285
+ * - http/https → must have at least one character after "://"
286
+ * - other :// → same (ftp, ws, etc.)
287
+ * - mailto/tel/… → must have something after the colon
288
+ * - //host → must have at least one character after "//"
289
+ * - #anchor → must have at least one character after "#"
290
+ * - /path → always valid (internal link)
291
+ * - plain text → must look like a domain (dot + 2+ letter TLD) or IP address
292
+ */
293
+ private isLinkComplete(url: string): boolean {
294
+ // http / https — require a non-empty host after "://"
295
+ if (/^https?:\/\//i.test(url)) {
296
+ return url.replace(/^https?:\/\//i, '').length > 0;
297
+ }
298
+ // Other double-slash protocols (ftp://, ws://, etc.)
299
+ if (/^\w+:\/\//.test(url)) {
300
+ return url.replace(/^\w+:\/\//, '').length > 0;
301
+ }
302
+ // Single-colon schemes: mailto:, tel:, sms:, etc. — require something after ":"
303
+ if (/^\w+:/.test(url)) {
304
+ return url.slice(url.indexOf(':') + 1).length > 0;
305
+ }
306
+ // Protocol-relative — require a non-empty host after "//"
307
+ if (url.startsWith('//')) {
308
+ return url.slice(2).length > 0;
309
+ }
310
+ // Anchor — require at least one character after "#"
311
+ if (url.startsWith('#')) {
312
+ return url.length > 1;
313
+ }
314
+ // Absolute internal path — always valid
315
+ if (url.startsWith('/')) {
316
+ return true;
317
+ }
318
+ // Plain text — must look like a domain or IP address
319
+ return /\.[a-zA-Z]{2,}/.test(url) || /^\d{1,3}(\.\d{1,3}){3}/.test(url);
320
+ }
321
+
322
+ /**
323
+ * Return the icon SVG and human-readable label for a given URL
324
+ */
325
+ private getLinkTypeInfo(url: string): { icon: string; label: string } {
326
+ if (url.startsWith('mailto:')) {
327
+ return { icon: IconMail, label: 'Email address' };
328
+ }
329
+ if (url.startsWith('#')) {
330
+ return { icon: IconHash, label: 'Jump to section' };
331
+ }
332
+
333
+ return { icon: IconGlobe, label: 'Link to web page' };
334
+ }
335
+
336
+ /**
337
+ * Insert the link from the input — called by the suggestion chip click
338
+ */
339
+ private confirmLink(): void {
340
+ if (!this.nodes.input) {
341
+ return;
342
+ }
343
+
344
+ const value = this.nodes.input.value || '';
345
+
346
+ if (!value.trim() || !this.isLinkComplete(value.trim())) {
347
+ return;
348
+ }
349
+
350
+ if (!this.validateURL(value)) {
351
+ this.notifier.show({
352
+ message: this.i18n.t('tools.link.invalidLink'),
353
+ style: 'error',
354
+ });
355
+
356
+ return;
357
+ }
358
+
359
+ const preparedValue = this.prepareLink(value);
360
+
361
+ this.selection.removeFakeBackground();
362
+ this.selection.restore();
363
+ this.insertLink(preparedValue);
364
+ this.selection.collapseToEnd();
365
+ this.inlineToolbar.close();
366
+ }
367
+
174
368
  /**
175
369
  * Shortcut for the link tool
176
370
  */
@@ -202,6 +396,8 @@ export class LinkInlineTool implements InlineTool {
202
396
  this.nodes.input.value = '';
203
397
  }
204
398
 
399
+ this.updateSuggestion(this.nodes.input.value);
400
+
205
401
  this.nodes.input.className = twMerge(this.INPUT_BASE_CLASSES, 'block');
206
402
  this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, true);
207
403
 
@@ -310,6 +506,7 @@ export class LinkInlineTool implements InlineTool {
310
506
  this.nodes.input.className = this.INPUT_BASE_CLASSES;
311
507
  this.setBooleanStateAttribute(this.nodes.input, this.DATA_ATTRIBUTES.inputOpened, false);
312
508
  this.nodes.input.value = '';
509
+ this.nodes.suggestion?.classList.add('hidden');
313
510
  this.updateButtonStateAttributes(false);
314
511
  this.unlinkAvailable = false;
315
512
  if (clearSavedSelection) {