feat(editor): link insertion full UI and flow

This commit is contained in:
Florian Sylvain committed 2026-10-12 17:12:36 +02:00
1 parent 03b39e64f2
commit 6f9ae6207a
16 files changed
+2865 -14

No files matched your search

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { useScrollEdges } from '../useScrollEdges'
function mountEdges() {
const element = document.createElement('div')
let edges!: ReturnType<typeof useScrollEdges>
const Host = defineComponent({
setup() {
edges = useScrollEdges(ref(element))
return () => h('span')
},
})
mount(Host)
return { element, edges }
}
function layout(element: HTMLElement, scrollLeft: number) {
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
element.scrollLeft = scrollLeft
element.dispatchEvent(new Event('scroll'))
}
describe('useScrollEdges', () => {
it('hides nothing at the start except the end', async () => {
const { element, edges } = mountEdges()
layout(element, 0)
await nextTick()
expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([false, true])
})
it('hides both sides in the middle', async () => {
const { element, edges } = mountEdges()
layout(element, 100)
await nextTick()
expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([true, true])
})
it('hides only the start at the end, within one pixel', async () => {
const { element, edges } = mountEdges()
layout(element, 710)
await nextTick()
expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([true, false])
layout(element, 709)
await nextTick()
expect(edges.hiddenAfter.value).toBe(false)
})
it('shows no fade when the content fits', async () => {
const { element, edges } = mountEdges()
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 500 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 500 })
element.dispatchEvent(new Event('scroll'))
await nextTick()
expect([edges.hiddenBefore.value, edges.hiddenAfter.value]).toEqual([false, false])
})
})
+174
View File
@@ -0,0 +1,174 @@
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { t } from '@/i18n'
import {
applyLinkEdit,
describeLinkDialog,
imageIsSelected,
linkRangeAtPos,
linkTouchesSelection,
removeLinkInRange,
textOfRange,
} from '@/lib/articleLink'
import type { LinkDialogDraft, LinkEdit, LinkPopoverTarget, TextRange } from '@/lib/articleLink'
const LINK_TOOLTIP_DELAY_MS = 400
const COARSE_POINTER_QUERY = '(pointer: coarse)'
const MAC_PLATFORM = /mac|iphone|ipad|ipod/i
const LINK_BUTTON_FOCUS_CLASS =
'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary'
const LINK_BUTTON_CLASS = `text-toned hover:text-highlighted ${LINK_BUTTON_FOCUS_CLASS}`
const LINK_BUTTON_ACTIVE_CLASS = `bg-(--rc-sand) text-(--rc-on-sand) hover:bg-(--rc-sand) hover:text-(--rc-on-sand) ${LINK_BUTTON_FOCUS_CLASS}`
export type LinkToolbarItem = EditorToolbarItem & {
'aria-label': string
'aria-pressed': 'true' | 'false'
'aria-disabled'?: 'true'
}
interface LinkPopoverState {
element: HTMLElement | null
text: string
href: string
range: TextRange
}
function isMacPlatform() {
return MAC_PLATFORM.test(navigator.platform)
}
export function useArticleLink(editorRef: Ref<Editor | null>, isDisabled: () => boolean) {
const isTouch = useMediaQuery(COARSE_POINTER_QUERY)
const dialogOpen = ref(false)
const dialog = ref<LinkDialogDraft>({
mode: 'insert',
text: '',
href: '',
newTab: false,
range: { from: 0, to: 0 },
})
const popoverOpen = ref(false)
const popover = ref<LinkPopoverState>({
element: null,
text: '',
href: '',
range: { from: 0, to: 0 },
})
function editableEditor(): Editor | null {
const editor = editorRef.value
if (!editor || isDisabled() || !editor.isEditable || imageIsSelected(editor)) {
return null
}
return editor
}
function openDialog() {
const editor = editableEditor()
if (!editor) {
return
}
popoverOpen.value = false
dialog.value = describeLinkDialog(editor)
dialogOpen.value = true
}
function openPopover(target: LinkPopoverTarget) {
const editor = editableEditor()
const range = editor ? linkRangeAtPos(editor, target.pos) : null
if (!editor || !range) {
return
}
popover.value = {
element: target.element,
text: textOfRange(editor, range),
href: range.attrs.href,
range: { from: range.from, to: range.to },
}
popoverOpen.value = true
}
function apply(edit: LinkEdit) {
const editor = editorRef.value
dialogOpen.value = false
if (editor) {
applyLinkEdit(editor, dialog.value, edit)
}
}
function removeFromDialog() {
dialogOpen.value = false
if (editorRef.value) {
removeLinkInRange(editorRef.value, dialog.value.range)
}
}
function editFromPopover() {
editorRef.value?.commands.setTextSelection(popover.value.range)
openDialog()
}
function openFromPopover() {
window.open(popover.value.href, '_blank', 'noopener,noreferrer')
}
function removeFromPopover() {
if (editorRef.value) {
removeLinkInRange(editorRef.value, popover.value.range)
}
}
watch(dialogOpen, (isOpen, wasOpen) => {
if (wasOpen && !isOpen) {
editorRef.value?.commands.focus()
}
})
watch(popoverOpen, (isOpen, wasOpen) => {
if (wasOpen && !isOpen && !dialogOpen.value) {
editorRef.value?.commands.focus()
}
})
function toolbarItem(editor: Editor): LinkToolbarItem {
const blocked = imageIsSelected(editor)
const active = !blocked && linkTouchesSelection(editor)
return {
icon: 'i-lucide-link',
class: active ? LINK_BUTTON_ACTIVE_CLASS : LINK_BUTTON_CLASS,
'aria-label': t('Insert link'),
'aria-pressed': active ? 'true' : 'false',
'aria-disabled': blocked ? 'true' : undefined,
tooltip:
blocked || isTouch.value
? undefined
: {
delayDuration: LINK_TOOLTIP_DELAY_MS,
get text() {
return isMacPlatform()
? t('Insert link (⌘K)')
: t('Insert link (Ctrl+K)')
},
},
onClick: () => openDialog(),
}
}
return {
dialogOpen,
dialog,
popoverOpen,
popover,
openDialog,
openPopover,
apply,
removeFromDialog,
editFromPopover,
openFromPopover,
removeFromPopover,
toolbarItem,
}
}
+47
View File
@@ -0,0 +1,47 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import type { Ref } from 'vue'
const EDGE_TOLERANCE_PX = 1
export function useScrollEdges(target: Ref<HTMLElement | null>) {
const hiddenBefore = ref(false)
const hiddenAfter = ref(false)
let observer: ResizeObserver | null = null
let attached: HTMLElement | null = null
function measure() {
const element = attached
if (!element) {
return
}
hiddenBefore.value = element.scrollLeft > EDGE_TOLERANCE_PX
hiddenAfter.value =
element.scrollLeft + element.clientWidth < element.scrollWidth - EDGE_TOLERANCE_PX
}
function detach() {
attached?.removeEventListener('scroll', measure)
observer?.disconnect()
observer = null
attached = null
}
function attach(element: HTMLElement | null) {
detach()
if (!element) {
return
}
attached = element
element.addEventListener('scroll', measure, { passive: true })
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(measure)
observer.observe(element)
}
measure()
}
watch(target, attach, { immediate: true, flush: 'post' })
onBeforeUnmount(detach)
return { hiddenBefore, hiddenAfter }
}