feat(editor): link insertion full UI and flow
This commit is contained in:
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])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user