feat(ui): add link popover component for inline link editing
- add LinkPopover.client.js component for creating, editing, and removing links - replace autoOpenLink ref-based approach with dedicated linkPopover state - import and integrate removeMark utility in BlockEditor - wire up handleLinkPopoverSet and handleLinkPopoverRemove handlers - open link popover on link click instead of expanding caret range - close link popover on mousedown outside popover and toolbar - refactor InlineToolbar to delegate link editing to linkPopover
This commit is contained in:
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import Block from './Block.client.js';
|
import Block from './Block.client.js';
|
||||||
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
|
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
|
||||||
import InlineToolbar from './inline/Toolbar.client.js';
|
import InlineToolbar from './inline/Toolbar.client.js';
|
||||||
|
import LinkPopover from './inline/LinkPopover.client.js';
|
||||||
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
|
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
|
||||||
import { registerBuiltInBlocks } from './blockTypes/index.js';
|
import { registerBuiltInBlocks } from './blockTypes/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
concatInline,
|
concatInline,
|
||||||
toggleMark,
|
toggleMark,
|
||||||
setMark,
|
setMark,
|
||||||
|
removeMark,
|
||||||
removeAllMarks,
|
removeAllMarks,
|
||||||
marksInRange,
|
marksInRange,
|
||||||
linkRangeAt,
|
linkRangeAt,
|
||||||
@@ -559,9 +561,10 @@ export default function BlockEditor({
|
|||||||
// Ancrée au-dessus de la sélection courante quand elle est non-vide et
|
// Ancrée au-dessus de la sélection courante quand elle est non-vide et
|
||||||
// qu'elle se trouve dans un contentEditable de l'éditeur.
|
// qu'elle se trouve dans un contentEditable de l'éditeur.
|
||||||
const [toolbar, setToolbar] = useState(null);
|
const [toolbar, setToolbar] = useState(null);
|
||||||
// { blockId, start, end, rect, autoOpenLink }
|
// { blockId, start, end, rect }
|
||||||
const toolbarPinnedRef = useRef(false);
|
const toolbarPinnedRef = useRef(false);
|
||||||
const autoOpenLinkRef = useRef(false);
|
const [linkPopover, setLinkPopover] = useState(null);
|
||||||
|
// { rect: DOMRect, mark, blockId, start, end }
|
||||||
|
|
||||||
// Couleurs déjà utilisées dans le document (hors palette par défaut).
|
// Couleurs déjà utilisées dans le document (hors palette par défaut).
|
||||||
// Présentées comme choix rapides dans le popover de couleur du toolbar.
|
// Présentées comme choix rapides dans le popover de couleur du toolbar.
|
||||||
@@ -605,9 +608,8 @@ export default function BlockEditor({
|
|||||||
const r = ref?.getCaretRange?.();
|
const r = ref?.getCaretRange?.();
|
||||||
if (!r || r.start === r.end) { setToolbar(null); return; }
|
if (!r || r.start === r.end) { setToolbar(null); return; }
|
||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
const autoOpenLink = autoOpenLinkRef.current;
|
setLinkPopover(null);
|
||||||
autoOpenLinkRef.current = false;
|
setToolbar({ blockId, start: r.start, end: r.end, rect });
|
||||||
setToolbar({ blockId, start: r.start, end: r.end, rect, autoOpenLink });
|
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -644,6 +646,29 @@ export default function BlockEditor({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLinkPopoverSet(href, newTab) {
|
||||||
|
if (!linkPopover) return;
|
||||||
|
const { blockId, start, end } = linkPopover;
|
||||||
|
const block = blocks.find(b => b.id === blockId);
|
||||||
|
if (!block) return;
|
||||||
|
const mark = { type: 'link', href, ...(newTab ? { newTab: true } : {}) };
|
||||||
|
const next = setMark(block.content ?? [], start, end, mark);
|
||||||
|
const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
|
||||||
|
commitChange(nextBlocks, { immediate: true });
|
||||||
|
setLinkPopover(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLinkPopoverRemove() {
|
||||||
|
if (!linkPopover) return;
|
||||||
|
const { blockId, start, end } = linkPopover;
|
||||||
|
const block = blocks.find(b => b.id === blockId);
|
||||||
|
if (!block) return;
|
||||||
|
const next = removeMark(block.content ?? [], start, end, 'link');
|
||||||
|
const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
|
||||||
|
commitChange(nextBlocks, { immediate: true });
|
||||||
|
setLinkPopover(null);
|
||||||
|
}
|
||||||
|
|
||||||
function applyRemoveAllMarks() {
|
function applyRemoveAllMarks() {
|
||||||
if (!toolbar) return;
|
if (!toolbar) return;
|
||||||
const { blockId, start, end } = toolbar;
|
const { blockId, start, end } = toolbar;
|
||||||
@@ -914,13 +939,16 @@ export default function BlockEditor({
|
|||||||
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
|
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
|
||||||
const onHandle = target instanceof Element ? target.closest('button') : null;
|
const onHandle = target instanceof Element ? target.closest('button') : null;
|
||||||
const inToolbar = target instanceof Element ? target.closest('[data-inline-toolbar]') : null;
|
const inToolbar = target instanceof Element ? target.closest('[data-inline-toolbar]') : null;
|
||||||
|
const inLinkPopover = target instanceof Element ? target.closest('[data-link-popover]') : null;
|
||||||
|
|
||||||
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
|
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
|
||||||
if (onHandle || inToolbar) {
|
if (onHandle || inToolbar || inLinkPopover) {
|
||||||
if (onHandle && selectedBlockIds.size > 0) clearBlockSelection();
|
if (onHandle && selectedBlockIds.size > 0) clearBlockSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkPopover) setLinkPopover(null);
|
||||||
|
|
||||||
// Toute nouvelle interaction → reset de la sélection bloc en cours.
|
// Toute nouvelle interaction → reset de la sélection bloc en cours.
|
||||||
if (selectedBlockIds.size > 0) clearBlockSelection();
|
if (selectedBlockIds.size > 0) clearBlockSelection();
|
||||||
|
|
||||||
@@ -973,8 +1001,8 @@ export default function BlockEditor({
|
|||||||
if (!r) return;
|
if (!r) return;
|
||||||
const linkRange = linkRangeAt(block.content ?? [], r.start);
|
const linkRange = linkRangeAt(block.content ?? [], r.start);
|
||||||
if (!linkRange) return;
|
if (!linkRange) return;
|
||||||
autoOpenLinkRef.current = true;
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
ref.setCaretRange(linkRange.start, linkRange.end);
|
setLinkPopover({ rect, mark: linkRange.mark, blockId, start: linkRange.start, end: linkRange.end });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
|
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
|
||||||
@@ -1108,11 +1136,9 @@ export default function BlockEditor({
|
|||||||
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
|
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
|
||||||
return (
|
return (
|
||||||
<InlineToolbar
|
<InlineToolbar
|
||||||
key={toolbar.autoOpenLink ? 'link-auto' : 'default'}
|
|
||||||
rect={toolbar.rect}
|
rect={toolbar.rect}
|
||||||
activeMarks={marks}
|
activeMarks={marks}
|
||||||
usedColors={usedColors}
|
usedColors={usedColors}
|
||||||
initialPopover={toolbar.autoOpenLink ? 'link' : null}
|
|
||||||
onToggleMark={applyToggleMark}
|
onToggleMark={applyToggleMark}
|
||||||
onSetMark={applySetMark}
|
onSetMark={applySetMark}
|
||||||
onClearMarks={applyRemoveAllMarks}
|
onClearMarks={applyRemoveAllMarks}
|
||||||
@@ -1120,6 +1146,15 @@ export default function BlockEditor({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{linkPopover && (
|
||||||
|
<LinkPopover
|
||||||
|
rect={linkPopover.rect}
|
||||||
|
mark={linkPopover.mark}
|
||||||
|
onSetLink={handleLinkPopoverSet}
|
||||||
|
onRemoveLink={handleLinkPopoverRemove}
|
||||||
|
onClose={() => setLinkPopover(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const GAP = 8;
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
|
||||||
|
export default function LinkPopover({ rect, mark, onSetLink, onRemoveLink, onClose }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||||
|
const [url, setUrl] = useState(mark?.href ?? '');
|
||||||
|
const [newTab, setNewTab] = useState(mark?.newTab ?? false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!rect || typeof window === 'undefined') return;
|
||||||
|
const width = ref.current?.offsetWidth ?? 280;
|
||||||
|
const height = ref.current?.offsetHeight ?? 80;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
let top = rect.bottom + GAP;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN;
|
||||||
|
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||||
|
if (top + height + VIEWPORT_MARGIN > vh) top = rect.top - height - GAP;
|
||||||
|
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
|
||||||
|
setPos({ top, left });
|
||||||
|
}, [rect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); onClose?.(); }
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!url) return;
|
||||||
|
onSetLink?.(url, newTab);
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-link-popover
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 50 }}
|
||||||
|
className="flex flex-col gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit(); } }}
|
||||||
|
className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onRemoveLink?.(); onClose?.(); }}
|
||||||
|
title="Retirer le lien"
|
||||||
|
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-700 dark:text-neutral-300 select-none cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newTab}
|
||||||
|
onChange={(e) => setNewTab(e.target.checked)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
Ouvrir dans un nouvel onglet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,13 +23,12 @@ const SIMPLE_BUTTONS = [
|
|||||||
{ type: 'code', label: <CodeSimpleIcon width={15} height={15} />, title: 'Code (Ctrl+E)', className: '' },
|
{ type: 'code', label: <CodeSimpleIcon width={15} height={15} />, title: 'Code (Ctrl+E)', className: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors, initialPopover }) {
|
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
|
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
|
||||||
const [popover, setPopover] = useState(initialPopover ?? null);
|
const [popover, setPopover] = useState(null);
|
||||||
const existingLink = initialPopover === 'link' ? activeMarks?.find(m => m.type === 'link') : null;
|
const [linkUrl, setLinkUrl] = useState('');
|
||||||
const [linkUrl, setLinkUrl] = useState(existingLink?.href ?? '');
|
const [linkNewTab, setLinkNewTab] = useState(false);
|
||||||
const [linkNewTab, setLinkNewTab] = useState(existingLink ? !!existingLink.newTab : false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPinChange?.(popover !== null);
|
onPinChange?.(popover !== null);
|
||||||
|
|||||||
Reference in New Issue
Block a user