diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index 9c7bc58..81628b0 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Block from './Block.client.js'; import SlashMenu, { getSlashItems } from './SlashMenu.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 { registerBuiltInBlocks } from './blockTypes/index.js'; import { @@ -14,6 +15,7 @@ import { concatInline, toggleMark, setMark, + removeMark, removeAllMarks, marksInRange, linkRangeAt, @@ -559,9 +561,10 @@ 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, autoOpenLink } + // { blockId, start, end, rect } 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). // 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?.(); if (!r || r.start === r.end) { setToolbar(null); return; } const rect = range.getBoundingClientRect(); - const autoOpenLink = autoOpenLinkRef.current; - autoOpenLinkRef.current = false; - setToolbar({ blockId, start: r.start, end: r.end, rect, autoOpenLink }); + setLinkPopover(null); + setToolbar({ blockId, start: r.start, end: r.end, rect }); }, [disabled]); 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() { if (!toolbar) return; const { blockId, start, end } = toolbar; @@ -914,13 +939,16 @@ export default function BlockEditor({ const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null; const onHandle = target instanceof Element ? target.closest('button') : 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. - if (onHandle || inToolbar) { + if (onHandle || inToolbar || inLinkPopover) { if (onHandle && selectedBlockIds.size > 0) clearBlockSelection(); return; } + if (linkPopover) setLinkPopover(null); + // Toute nouvelle interaction → reset de la sélection bloc en cours. if (selectedBlockIds.size > 0) clearBlockSelection(); @@ -973,8 +1001,8 @@ export default function BlockEditor({ if (!r) return; const linkRange = linkRangeAt(block.content ?? [], r.start); if (!linkRange) return; - autoOpenLinkRef.current = true; - ref.setCaretRange(linkRange.start, linkRange.end); + const rect = sel.getRangeAt(0).getBoundingClientRect(); + 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 @@ -1108,11 +1136,9 @@ export default function BlockEditor({ const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : []; return ( ); })()} + {linkPopover && ( + setLinkPopover(null)} + /> + )} {error && (

{error}

)} diff --git a/src/shared/components/BlockEditor/inline/LinkPopover.client.js b/src/shared/components/BlockEditor/inline/LinkPopover.client.js new file mode 100644 index 0000000..b86bc85 --- /dev/null +++ b/src/shared/components/BlockEditor/inline/LinkPopover.client.js @@ -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 ( +
+
+ 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" + /> + + +
+ +
+ ); +} diff --git a/src/shared/components/BlockEditor/inline/Toolbar.client.js b/src/shared/components/BlockEditor/inline/Toolbar.client.js index f7d100c..f4186c5 100644 --- a/src/shared/components/BlockEditor/inline/Toolbar.client.js +++ b/src/shared/components/BlockEditor/inline/Toolbar.client.js @@ -23,13 +23,12 @@ const SIMPLE_BUTTONS = [ { type: 'code', label: , 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 [pos, setPos] = useState({ top: 0, left: 0, flipped: 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); + const [popover, setPopover] = useState(null); + const [linkUrl, setLinkUrl] = useState(''); + const [linkNewTab, setLinkNewTab] = useState(false); useEffect(() => { onPinChange?.(popover !== null);