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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user