@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.
- package/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-CdxHhr5i.mjs → blok-BmlbETK7.mjs} +2119 -2013
- package/dist/chunks/{constants-C_H9o9Ao.mjs → constants-WhLyFkza.mjs} +260 -223
- package/dist/chunks/{i18next-loader-D5HxE5ZQ.mjs → i18next-loader-CZARkla1.mjs} +1 -1
- package/dist/chunks/{lightweight-i18n-Safdy0ua.mjs → lightweight-i18n-BQa0F2X6.mjs} +9 -0
- package/dist/chunks/{tools-B0YXCZFW.mjs → tools-BCb5bMO3.mjs} +973 -843
- package/dist/full.mjs +3 -3
- package/dist/locales.mjs +9 -0
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +2 -2
- package/src/components/block/style-manager.ts +1 -1
- package/src/components/blocks.ts +26 -54
- package/src/components/constants/data-attributes.ts +0 -2
- package/src/components/i18n/locales/en/messages.json +9 -0
- package/src/components/icons/index.ts +34 -6
- package/src/components/inline-tools/inline-tool-link.ts +202 -5
- package/src/components/inline-tools/inline-tool-marker.ts +166 -23
- package/src/components/inline-tools/utils/formatting-range-utils.ts +10 -1
- package/src/components/modules/blockManager/blockManager.ts +2 -2
- package/src/components/modules/blockManager/operations.ts +2 -2
- package/src/components/modules/blockManager/repository.ts +1 -9
- package/src/components/modules/blockManager/types.ts +1 -1
- package/src/components/modules/drag/operations/DragOperations.ts +45 -6
- package/src/components/modules/paste/google-docs-preprocessor.ts +69 -2
- package/src/components/modules/paste/handlers/blok-data-handler.ts +96 -19
- package/src/components/modules/renderer.ts +2 -0
- package/src/components/modules/toolbar/blockSettings.ts +1 -1
- package/src/components/modules/toolbar/index.ts +21 -0
- package/src/components/modules/toolbar/plus-button.ts +15 -5
- package/src/components/selection/fake-background/index.ts +9 -10
- package/src/components/shared/color-picker.ts +108 -95
- package/src/components/shared/color-presets.ts +30 -2
- package/src/components/ui/toolbox.ts +36 -7
- package/src/components/utils/color-mapping.ts +43 -1
- package/src/components/utils/color-migration.ts +37 -0
- package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts +4 -3
- package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +5 -39
- package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts +2 -2
- package/src/components/utils/popover/components/popover-item/popover-item.ts +11 -0
- package/src/components/utils/popover/components/search-input/search-input.const.ts +2 -3
- package/src/components/utils/popover/components/search-input/search-input.ts +1 -32
- package/src/components/utils/popover/popover-abstract.ts +2 -4
- package/src/components/utils/popover/popover-desktop.ts +1 -16
- package/src/components/utils/popover/popover-inline.ts +1 -2
- package/src/components/utils/popover/popover-mobile.ts +2 -2
- package/src/components/utils/popover/popover.const.ts +1 -1
- package/src/stories/Table.stories.ts +15 -9
- package/src/styles/main.css +312 -14
- package/src/tools/header/index.ts +5 -5
- package/src/tools/list/constants.ts +11 -4
- package/src/tools/list/depth-validator.ts +13 -1
- package/src/tools/list/dom-builder.ts +5 -3
- package/src/tools/list/index.ts +3 -2
- package/src/tools/paragraph/index.ts +2 -2
- package/src/tools/table/table-cell-color-picker.ts +1 -1
- package/src/tools/table/table-cell-selection.ts +1 -2
- package/src/tools/table/table-core.ts +2 -2
- package/src/tools/table/table-grip-visuals.ts +13 -5
- package/src/tools/table/table-heading-toggle.ts +15 -9
- package/src/tools/table/table-row-col-controls.ts +17 -11
- package/src/tools/table/table-row-col-drag.ts +26 -3
- package/src/tools/toggle/constants.ts +5 -5
- package/src/tools/toggle/index.ts +1 -1
- package/types/tools/hook-events.d.ts +6 -0
- package/types/utils/popover/popover-item.d.ts +6 -0
- package/CHANGELOG.md +0 -119
package/dist/full.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as t, t as n } from "./chunks/blok-
|
|
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-
|
|
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-
|
|
2
|
-
import { r as e, t } from "./chunks/blok-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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',
|
package/src/components/blocks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
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
|
|
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,
|
|
444
|
-
const
|
|
428
|
+
private moveHolderInDOM(block: Block, toIndex: number): void {
|
|
429
|
+
const nextBlock = this.blocks[toIndex];
|
|
445
430
|
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
|
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
|
-
<
|
|
46
|
-
<path d="
|
|
47
|
-
<
|
|
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-
|
|
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
|
-
//
|
|
141
|
-
element: this.nodes.
|
|
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) {
|