diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index ef4b2cd..9c7bc58 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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 ( , 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); diff --git a/src/shared/components/BlockEditor/inline/types.js b/src/shared/components/BlockEditor/inline/types.js index 9eb4e3d..9fc90b1 100644 --- a/src/shared/components/BlockEditor/inline/types.js +++ b/src/shared/components/BlockEditor/inline/types.js @@ -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.