feat(ui): add "open in new tab" option to block editor link toolbar

- add `linkNewTab` state (default true) in InlineToolbar
- pass `newTab` flag through `onToggleMark` on link submit and remove
- restore `newTab` value when reopening the link popover
- add checkbox ui in link popover to toggle new tab behavior
- update link serialization to render `target="_blank" rel="noopener noreferrer"` when `newTab` is set
- add `newTab` field to link mark type definition
This commit is contained in:
2026-04-25 18:32:21 -04:00
parent 2c132b3a8a
commit fdb36c39e5
3 changed files with 49 additions and 40 deletions
@@ -27,6 +27,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false }); const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
const [linkUrl, setLinkUrl] = useState(''); const [linkUrl, setLinkUrl] = useState('');
const [linkNewTab, setLinkNewTab] = useState(true);
useEffect(() => { useEffect(() => {
onPinChange?.(popover !== null); onPinChange?.(popover !== null);
@@ -75,21 +76,22 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
function handleLinkSubmit(e) { function handleLinkSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!linkUrl) return; if (!linkUrl) return;
onToggleMark?.({ type: 'link', href: linkUrl }); onToggleMark?.({ type: 'link', href: linkUrl, newTab: linkNewTab });
setLinkUrl(''); setLinkUrl('');
setPopover(null); setPopover(null);
} }
function handleLinkRemove() { function handleLinkRemove() {
// Trouver le href actif pour reproduire la même mark (toggle off). // Trouver la mark active pour reproduire la même clé (toggle off).
const link = activeMarks.find(m => m.type === 'link'); const link = activeMarks.find(m => m.type === 'link');
if (link) onToggleMark?.({ type: 'link', href: link.href }); if (link) onToggleMark?.({ type: 'link', href: link.href, ...(link.newTab ? { newTab: true } : {}) });
setPopover(null); setPopover(null);
} }
function openLinkPopover() { function openLinkPopover() {
const link = activeMarks.find(m => m.type === 'link'); const link = activeMarks.find(m => m.type === 'link');
setLinkUrl(link?.href ?? ''); setLinkUrl(link?.href ?? '');
setLinkNewTab(link ? !!link.newTab : true);
setPopover(p => (p === 'link' ? null : 'link')); setPopover(p => (p === 'link' ? null : 'link'));
} }
@@ -157,32 +159,43 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
{popover === 'link' && ( {popover === 'link' && (
<form <form
onSubmit={handleLinkSubmit} onSubmit={handleLinkSubmit}
className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5" className="absolute top-full left-0 mt-1 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"
> >
<input <div className="flex items-center gap-1">
autoFocus <input
type="url" autoFocus
placeholder="https://..." type="url"
value={linkUrl} placeholder="https://..."
onChange={(e) => setLinkUrl(e.target.value)} value={linkUrl}
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" onChange={(e) => setLinkUrl(e.target.value)}
/> 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="submit"
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{isActive('link') && (
<button <button
type="button" type="submit"
onClick={handleLinkRemove} className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
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"
> >
OK
</button> </button>
)} {isActive('link') && (
<button
type="button"
onClick={handleLinkRemove}
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={linkNewTab}
onChange={(e) => setLinkNewTab(e.target.checked)}
className="cursor-pointer"
/>
Ouvrir dans un nouvel onglet
</label>
</form> </form>
)} )}
</div> </div>
@@ -14,14 +14,6 @@
import { INLINE_COLORS, normalize } from './types.js'; import { INLINE_COLORS, normalize } from './types.js';
const SIMPLE_TAGS = {
bold: 'STRONG',
italic: 'EM',
underline: 'U',
strike: 'S',
code: 'CODE',
};
const TAG_TO_MARK = { const TAG_TO_MARK = {
STRONG: 'bold', STRONG: 'bold',
B: 'bold', B: 'bold',
@@ -87,13 +79,14 @@ function buildNode(d, node) {
// 4. Lien — toujours à l'extérieur. // 4. Lien — toujours à l'extérieur.
const link = findMark(marks, 'link'); const link = findMark(marks, 'link');
if (link) { if (link) {
const attrs = { href: link.href };
if (link.newTab) {
attrs.target = '_blank';
attrs.rel = 'noopener noreferrer';
}
el = wrap(d, el, 'a', { el = wrap(d, el, 'a', {
className: 'text-blue-600 dark:text-blue-400 underline underline-offset-2', className: 'text-blue-600 dark:text-blue-400 underline underline-offset-2',
attrs: { attrs,
href: link.href,
rel: 'noopener noreferrer',
target: '_blank',
},
}); });
} }
@@ -149,7 +142,10 @@ function walk(node, marks, out) {
if (tag === 'A') { if (tag === 'A') {
const href = node.getAttribute('href') || ''; const href = node.getAttribute('href') || '';
if (href) added.push({ type: 'link', href }); if (href) {
const newTab = node.getAttribute('target') === '_blank';
added.push({ type: 'link', href, ...(newTab ? { newTab: true } : {}) });
}
} }
if (tag === 'SPAN') { if (tag === 'SPAN') {
@@ -48,7 +48,7 @@ export function markKey(mark) {
case 'highlight': case 'highlight':
return `${mark.type}:${mark.color}`; return `${mark.type}:${mark.color}`;
case 'link': case 'link':
return `link:${mark.href}`; return `link:${mark.newTab ? '1' : '0'}:${mark.href}`;
default: default:
return mark.type; return mark.type;
} }