feat(BlockEditor): auto-open link popover when clicking on existing link

- add `linkRangeAt` import to detect link span under caret
- handle `mouseup` on container to detect collapsed click inside a link mark
- set `autoOpenLink` flag via ref and expand selection to full link range
- pass `autoOpenLink` to toolbar state and use `initialPopover='link'` prop
- initialize toolbar popover state from `initialPopover` prop
- pre-fill link url and new-tab from existing mark when `initialPopover` is set
- add `linkRangeAt` helper in `types.js` to find enclosing link range at offset
This commit is contained in:
2026-04-26 15:11:00 -04:00
parent 33ee62e908
commit 8159b5316a
3 changed files with 60 additions and 6 deletions
@@ -16,6 +16,7 @@ import {
setMark,
removeAllMarks,
marksInRange,
linkRangeAt,
collectUsedColors,
} from './inline/types.js';
import { blocksToHtml, blocksToPlainText, htmlToBlocks } from './inline/clipboard.js';
@@ -558,8 +559,9 @@ export default function BlockEditor({
// 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.
const [toolbar, setToolbar] = useState(null);
// { blockId, start, end, rect, marks }
// { blockId, start, end, rect, autoOpenLink }
const toolbarPinnedRef = useRef(false);
const autoOpenLinkRef = useRef(false);
// 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.
@@ -603,7 +605,9 @@ export default function BlockEditor({
const r = ref?.getCaretRange?.();
if (!r || r.start === r.end) { setToolbar(null); return; }
const rect = range.getBoundingClientRect();
setToolbar({ blockId, start: r.start, end: r.end, rect });
const autoOpenLink = autoOpenLinkRef.current;
autoOpenLinkRef.current = false;
setToolbar({ blockId, start: r.start, end: r.end, rect, autoOpenLink });
}, [disabled]);
useEffect(() => {
@@ -954,6 +958,25 @@ export default function BlockEditor({
};
}
function handleContainerMouseUp(e) {
if (e.button !== 0) return;
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return;
const target = e.target;
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
if (!blockEl) return;
const blockId = blockEl.getAttribute('data-block-id');
const block = blocks.find(b => b.id === blockId);
if (!block) return;
const ref = blockRefs.current.get(blockId);
const r = ref?.getCaretRange?.();
if (!r) return;
const linkRange = linkRangeAt(block.content ?? [], r.start);
if (!linkRange) return;
autoOpenLinkRef.current = true;
ref.setCaretRange(linkRange.start, linkRange.end);
}
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
// sélectionné — on bascule en sélection « tous les blocs ».
function handleBlockSelectAll() {
@@ -1030,6 +1053,7 @@ export default function BlockEditor({
ref={containerRef}
onKeyDown={handleGlobalKeyDown}
onMouseDownCapture={handleContainerMouseDown}
onMouseUp={handleContainerMouseUp}
style={minHeight != null ? { minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight } : undefined}
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 pl-0 pr-[45px] py-5 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 ? 'block-editor--sole-empty' : ''} ${className}`}
>
@@ -1084,9 +1108,11 @@ export default function BlockEditor({
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
return (
<InlineToolbar
key={toolbar.autoOpenLink ? 'link-auto' : 'default'}
rect={toolbar.rect}
activeMarks={marks}
usedColors={usedColors}
initialPopover={toolbar.autoOpenLink ? 'link' : null}
onToggleMark={applyToggleMark}
onSetMark={applySetMark}
onClearMarks={applyRemoveAllMarks}
@@ -23,12 +23,13 @@ const SIMPLE_BUTTONS = [
{ type: 'code', label: <CodeSimpleIcon width={15} height={15} />, title: 'Code (Ctrl+E)', className: '' },
];
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors }) {
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors, initialPopover }) {
const ref = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
const [linkUrl, setLinkUrl] = useState('');
const [linkNewTab, setLinkNewTab] = useState(false);
const [popover, setPopover] = useState(initialPopover ?? null);
const existingLink = initialPopover === 'link' ? activeMarks?.find(m => m.type === 'link') : null;
const [linkUrl, setLinkUrl] = useState(existingLink?.href ?? '');
const [linkNewTab, setLinkNewTab] = useState(existingLink ? !!existingLink.newTab : false);
useEffect(() => {
onPinChange?.(popover !== null);
@@ -205,6 +205,33 @@ export function marksAtOffset(nodes, offset) {
return last.marks ? last.marks.map(m => ({ ...m })) : [];
}
// Trouve le début et la fin continus du span qui porte un lien à `offset`.
// Retourne { start, end, mark } ou null si aucun lien à cet offset.
export function linkRangeAt(nodes, offset) {
if (!Array.isArray(nodes) || nodes.length === 0) return null;
const marks = marksAtOffset(nodes, offset);
const linkMark = marks.find(m => m.type === 'link');
if (!linkMark) return null;
const key = markKey(linkMark);
let pos = 0;
let start = null;
let end = null;
for (const node of nodes) {
const len = node.text?.length ?? 0;
const nodeEnd = pos + len;
const hasLink = (node.marks ?? []).some(m => markKey(m) === key);
if (hasLink) {
if (start === null) start = pos;
end = nodeEnd;
} else if (start !== null) {
break;
}
pos = nodeEnd;
}
if (start === null) return null;
return { start, end, mark: linkMark };
}
// Marks communes à toute la plage [start, end[. Si la plage est vide,
// retourne les marks à l'offset `start`. Utile pour afficher l'état actif
// du toolbar.