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

+10
View File
@@ -315,6 +315,16 @@ body {
.article-content a:hover {
text-decoration-thickness: 2px;
}
.article-content a:focus-visible,
.article-content .article-link-focus {
outline: 2px solid var(--ui-primary);
outline-offset: 2px;
}
.article-content a::selection,
.article-content a *::selection {
background: var(--rc-sand);
color: var(--rc-on-sand);
}
.article-content img {
display: block;
max-width: 100%;
+80 -8
View File
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import LinkModal from '@/components/LinkModal.vue'
import LinkPopover from '@/components/LinkPopover.vue'
import { useArticleLink } from '@/composables/useArticleLink'
import { useScrollEdges } from '@/composables/useScrollEdges'
import { ArticleLink } from '@/lib/articleLink'
import { ArticleIndent } from '@/lib/articleIndent'
import { ArticleTextAlign } from '@/lib/articleAlign'
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
@@ -25,10 +30,18 @@ const emit = defineEmits<{
'request-remove-image': [imageId: number]
}>()
const editorInstance = ref<Editor | null>(null)
const editorInstance = shallowRef<Editor | null>(null)
const toolbarRef = ref<{ $el: HTMLElement } | null>(null)
const toolbarElement = computed(() => toolbarRef.value?.$el ?? null)
const { hiddenBefore, hiddenAfter } = useScrollEdges(toolbarElement)
const link = useArticleLink(editorInstance, () => props.disabled)
const extensions = [
ArticleIndent,
ArticleTextAlign,
ArticleLink.configure({
onRequestDialog: () => link.openDialog(),
onRequestPopover: (target) => link.openPopover(target),
}),
ArticleImage.configure({
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
}),
@@ -276,6 +289,7 @@ function buildItems(editor: Editor): EditorToolbarItem[][] {
},
],
[
link.toolbarItem(editor),
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
@@ -342,6 +356,7 @@ defineExpose({
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
:starter-kit="{ link: false }"
:image="false"
content-type="html"
:editable="!props.disabled"
@@ -351,18 +366,51 @@ defineExpose({
>
<span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorDragHandle :editor="editor" />
<UEditorToolbar
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="col-span-full row-start-1 min-w-0 overflow-x-auto border-b border-default bg-default px-6 py-1.5 [&>*]:shrink-0"
/>
<div class="relative col-span-full row-start-1 min-w-0">
<UEditorToolbar
ref="toolbarRef"
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="article-toolbar min-w-0 overflow-x-auto border-b border-default bg-default px-2 py-1.5 sm:px-6 [&>*]:shrink-0"
/>
<span
v-if="hiddenBefore"
data-testid="toolbar-fade-start"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 w-6 bg-linear-to-r from-default to-transparent"
/>
<span
v-if="hiddenAfter"
data-testid="toolbar-fade-end"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-default to-transparent"
/>
</div>
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
<slot name="aside" />
</div>
<LinkModal
v-model:open="link.dialogOpen.value"
:mode="link.dialog.value.mode"
:text="link.dialog.value.text"
:href="link.dialog.value.href"
:new-tab="link.dialog.value.newTab"
@submit="link.apply"
@remove="link.removeFromDialog"
/>
<LinkPopover
v-model:open="link.popoverOpen.value"
:href="link.popover.value.href"
:text="link.popover.value.text"
:reference="link.popover.value.element"
@edit="link.editFromPopover"
@open-link="link.openFromPopover"
@remove="link.removeFromPopover"
/>
</UEditor>
</template>
@@ -380,6 +428,30 @@ defineExpose({
padding: 2.5rem 3rem;
outline: none;
}
.article-editor :deep(.ProseMirror ul),
.article-editor :deep(.ProseMirror ol) {
padding-left: 1.75rem;
}
.article-editor :deep(.ProseMirror li) {
margin: 0;
padding-left: 0;
}
.article-editor :deep(.ProseMirror h2) {
margin-top: 2rem;
}
.article-toolbar {
scrollbar-width: none;
}
.article-toolbar::-webkit-scrollbar {
display: none;
}
@media (max-width: 39.999rem) {
.article-toolbar :deep(button) {
min-width: 2.5rem;
min-height: 2.5rem;
justify-content: center;
}
}
@media (max-width: 47.999rem) {
.article-editor :deep(.ProseMirror) {
padding: 1.5rem 1rem;
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { normalizeLinkUrl } from '@/lib/articleLinkUrl'
const SHEET_QUERY = '(max-width: 39.999rem)'
const TEXT_PLACEHOLDER = 'What the reader sees'
const URL_PLACEHOLDER = 'https://example.com, /page, mailto: or #anchor'
const DIALOG_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0'
const SHEET_CONTENT_CLASS =
'divide-y-0 shadow-(--shadow-modal) ring-0 !inset-x-0 !top-auto !bottom-0 !left-0 !w-auto !max-w-none !translate-x-0 !translate-y-0 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-accented'
export interface LinkModalSubmit {
text: string
href: string
newTab: boolean
}
const props = defineProps<{
mode: 'insert' | 'edit'
text: string
href: string
newTab: boolean
}>()
const emit = defineEmits<{
submit: [value: LinkModalSubmit]
remove: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isSheet = useMediaQuery(SHEET_QUERY)
const textId = useId()
const urlId = useId()
const formId = useId()
const textValue = ref('')
const urlValue = ref('')
const newTabValue = ref(false)
const textError = ref<MessageKey | ''>('')
const urlError = ref<MessageKey | ''>('')
const focusedField = ref<'text' | 'url' | ''>('')
const editing = computed(() => props.mode === 'edit')
const title = computed(() => (editing.value ? t('Edit link') : t('Insert link')))
const confirmLabel = computed(() => (editing.value ? t('Save link') : t('Insert link')))
const presentation = computed(() => (isSheet.value ? 'sheet' : 'dialog'))
const modalUi = computed(() => ({
content: isSheet.value ? SHEET_CONTENT_CLASS : DIALOG_CONTENT_CLASS,
header: isSheet.value
? 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6'
: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: isSheet.value ? 'pe-10 max-sm:pe-18' : 'pe-10',
close: isSheet.value ? 'end-8 top-9 size-11' : '',
title: 'text-lg/6 font-semibold',
description: 'text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}))
const modalContent = computed(() => ({
'data-presentation': presentation.value,
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
focusInitialField()
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}))
function focusField(id: string) {
document.getElementById(id)?.focus()
}
async function focusInitialField() {
await nextTick()
focusField(props.text === '' ? textId : urlId)
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
textValue.value = props.text
urlValue.value = props.href
newTabValue.value = props.newTab
textError.value = ''
urlError.value = ''
focusedField.value = ''
},
{ immediate: true },
)
function validateText() {
textError.value = textValue.value.trim() === '' ? 'Enter the link text.' : ''
}
function resolveHref() {
const keptAsStored =
props.mode === 'edit' &&
props.href.trim() !== '' &&
urlValue.value.trim() === props.href.trim()
return keptAsStored ? urlValue.value.trim() : normalizeLinkUrl(urlValue.value)
}
function validateUrl() {
urlError.value =
resolveHref() === null ? 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' : ''
}
function onUrlBlur() {
focusedField.value = ''
if (urlValue.value.trim() !== '') {
validateUrl()
}
}
function onSubmit() {
validateText()
validateUrl()
if (textError.value) {
focusField(textId)
return
}
if (urlError.value) {
focusField(urlId)
return
}
emit('submit', {
text: textValue.value.trim(),
href: resolveHref() ?? '',
newTab: newTabValue.value,
})
}
function onRemove() {
emit('remove')
}
function onCancel() {
open.value = false
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="t('Links open in the same tab unless you turn on a new tab.')"
:content="modalContent"
:ui="modalUi"
>
<template #body>
<UForm
:id="formId"
:loading-auto="false"
:state="{ text: textValue, url: urlValue }"
class="flex flex-col gap-4"
@submit="onSubmit"
>
<UFormField
:label="t('Link text')"
name="text"
:error="textError ? t(textError) : undefined"
@focusin="focusedField = 'text'"
@focusout="focusedField = ''"
>
<template v-if="textError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="textId"
v-model="textValue"
:placeholder="t(TEXT_PLACEHOLDER)"
color="primary"
autocomplete="off"
:highlight="focusedField === 'text' || !!textError"
:aria-invalid="textError ? 'true' : undefined"
:ui="{ base: 'h-11 text-base md:h-10' }"
class="w-full"
/>
</UFormField>
<UFormField
:label="t('URL')"
name="url"
:error="urlError ? t(urlError) : undefined"
@focusin="focusedField = 'url'"
@focusout="onUrlBlur"
>
<template v-if="urlError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
:id="urlId"
v-model="urlValue"
:placeholder="t(URL_PLACEHOLDER)"
:title="urlValue"
color="primary"
autocomplete="off"
spellcheck="false"
:highlight="focusedField === 'url' || !!urlError"
:aria-invalid="urlError ? 'true' : undefined"
:ui="{ root: 'min-w-0', base: 'h-11 text-base md:h-10' }"
class="w-full min-w-0"
/>
</UFormField>
<USwitch
v-model="newTabValue"
:label="t('Open in new tab')"
:ui="{
root: 'min-h-11 items-center gap-3 md:min-h-10',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
wrapper: 'ms-0',
label: 'text-base font-medium text-highlighted',
}"
/>
<UButton
v-if="editing"
color="error"
variant="link"
icon="i-lucide-unlink"
class="min-h-11 self-start px-0 md:min-h-10"
@click="onRemove"
>
{{ t('Remove link') }}
</UButton>
</UForm>
</template>
<template #footer>
<div
class="flex w-full gap-2"
:class="isSheet ? 'flex-col-reverse' : 'flex-row justify-end'"
>
<UButton
color="neutral"
variant="outline"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
@click="onCancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
:form="formId"
:block="isSheet"
:ui="{ base: 'h-11 justify-center md:h-8' }"
>
{{ confirmLabel }}
</UButton>
</div>
</template>
</UModal>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
const COARSE_POINTER_QUERY = '(pointer: coarse)'
const ACTION_CLASS = 'h-8 gap-1.5 px-2.5 text-sm font-normal'
const ROW_CLASS = 'h-12 justify-start gap-3 px-4 text-md font-normal'
defineProps<{
href: string
text: string
reference: HTMLElement | null
}>()
const emit = defineEmits<{
edit: []
'open-link': []
remove: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isTouch = useMediaQuery(COARSE_POINTER_QUERY)
const popoverContent = {
side: 'bottom' as const,
align: 'start' as const,
sideOffset: 8,
onOpenAutoFocus: (event: Event) => event.preventDefault(),
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
const sheetContent = {
onOpenAutoFocus: (event: Event) => {
event.preventDefault()
const container = event.currentTarget as HTMLElement | null
container?.focus()
},
onCloseAutoFocus: (event: Event) => event.preventDefault(),
}
function choose(action: 'edit' | 'openLink' | 'remove') {
open.value = false
if (action === 'edit') {
emit('edit')
} else if (action === 'openLink') {
emit('open-link')
} else {
emit('remove')
}
}
</script>
<template>
<USlideover
v-if="isTouch"
v-model:open="open"
side="bottom"
:title="text"
:description="href"
:close="false"
:content="sheetContent"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div role="dialog" :aria-label="t('Link')">
<div class="mx-auto mt-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="flex flex-col gap-0.5 border-b border-default px-4 pt-3 pb-3">
<span class="text-lg/6 font-semibold text-highlighted">{{ text }}</span>
<span class="truncate font-mono text-xs/4.5 text-toned">{{ href }}</span>
</div>
<div class="flex flex-col pt-2 pb-4">
<UButton
color="neutral"
variant="ghost"
block
icon="i-lucide-pencil"
:class="[ROW_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('edit')"
>
{{ t('Edit link') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
block
icon="i-lucide-external-link"
:class="[ROW_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('openLink')"
>
{{ t('Open link') }}
</UButton>
<UButton
color="error"
variant="ghost"
block
icon="i-lucide-unlink"
:class="ROW_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="choose('remove')"
>
{{ t('Remove link') }}
</UButton>
</div>
</div>
</template>
</USlideover>
<UPopover
v-else
v-model:open="open"
:reference="reference ?? undefined"
:content="popoverContent"
:ui="{
content:
'w-72 rounded-none bg-default shadow-(--rc-shadow-pop) ring-0 border border-default',
}"
>
<template #content>
<div role="dialog" :aria-label="t('Link')" class="flex flex-col text-sm text-default">
<div
class="flex items-center gap-2 border-b border-default px-3 py-2 font-mono text-xs/4.5 text-toned"
>
<UIcon name="i-lucide-link" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate" :title="href">{{ href }}</span>
</div>
<div class="flex gap-0.5 p-1">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-pencil"
:class="[ACTION_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('edit')"
>
{{ t('Edit') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-external-link"
:class="[ACTION_CLASS, 'text-highlighted']"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('openLink')"
>
{{ t('Open') }}
</UButton>
<UButton
color="error"
variant="ghost"
icon="i-lucide-unlink"
:class="ACTION_CLASS"
:ui="{ leadingIcon: 'size-3.5' }"
@click="choose('remove')"
>
{{ t('Remove') }}
</UButton>
</div>
</div>
</template>
</UPopover>
</template>
File diff suppressed because it is too large. Load diff
@@ -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 }
}
+21
View File
@@ -336,6 +336,27 @@ export const messages = {
Indent: 'Augmenter le retrait',
Outdent: 'Réduire le retrait',
'Clear formatting': 'Effacer la mise en forme',
'More actions': 'Plus d’actions',
'Insert link': 'Insérer un lien',
'Insert link (Ctrl+K)': 'Insérer un lien (Ctrl+K)',
'Insert link (⌘K)': 'Insérer un lien (⌘K)',
'Edit link': 'Modifier le lien',
'Save link': 'Enregistrer le lien',
'Remove link': 'Retirer le lien',
'Open link': 'Ouvrir le lien',
Link: 'Lien',
'Link text': 'Texte du lien',
'What the reader sees': 'Ce que voit le lecteur',
'https://example.com, /page, mailto: or #anchor':
'https://exemple.com, /page, mailto: ou #ancre',
'Open in new tab': 'Ouvrir dans un nouvel onglet',
'Links open in the same tab unless you turn on a new tab.':
'Les liens s’ouvrent dans le même onglet, sauf si vous activez un nouvel onglet.',
'Enter the link text.': 'Saisissez le texte du lien.',
'Enter a valid URL. Use http(s), mailto, a path or #anchor.':
'Saisissez une URL valide. Utilisez http(s), mailto, un chemin ou #ancre.',
Open: 'Ouvrir',
Remove: 'Retirer',
Image: 'Image',
'Add an image': 'Ajouter une image',
'Remove image': 'Retirer l’image',
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { isStoredHrefSafe, neutralizeUnsafeLinks, normalizeLinkUrl } from '../articleLinkUrl'
describe('normalizeLinkUrl accepts', () => {
it.each([
['https://example.org/a?b=1#c', 'https://example.org/a?b=1#c'],
['HTTP://Example.org', 'HTTP://Example.org'],
['mailto:hello@example.org', 'mailto:hello@example.org'],
['/articles/winter-checklist', '/articles/winter-checklist'],
['#measuring', '#measuring'],
[' /page ', '/page'],
])('%s', (raw, href) => {
expect(normalizeLinkUrl(raw)).toBe(href)
})
})
describe('normalizeLinkUrl rejects', () => {
it.each([
'www.example',
'example.com',
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
' javascript:alert(1)',
'java&#115;cript:alert(1)',
'\u0001javascript:alert(1)',
'java\tscript:alert(1)',
'data:text/html;base64,PHA+',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'ftp://example.org/a',
'tel:+33123456789',
'//evil.example',
'/\\evil.example',
'page.html',
'../x',
'http://',
'https://exa mple.org',
'',
'#',
'https://exa\u0001mple.org',
'/\u0001evil',
'#\u0001',
'/a\u007fb',
'/a\u0085b',
])('%j', (raw) => {
expect(normalizeLinkUrl(raw)).toBeNull()
})
})
describe('isStoredHrefSafe', () => {
it.each([
'/a',
'#a',
'page.html',
'../x',
'https://a.example',
'mailto:a@b.example',
'tel:+3312',
])('keeps %s', (href) => {
expect(isStoredHrefSafe(href)).toBe(true)
})
it.each([
'javascript:alert(1)',
' JavaScript:alert(1)',
'java\nscript:alert(1)',
'\u0001javascript:alert(1)',
'data:text/html,<b>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'',
])('neutralises %s', (href) => {
expect(isStoredHrefSafe(href)).toBe(false)
})
})
describe('neutralizeUnsafeLinks', () => {
it('unwraps unsafe anchors and keeps their text', () => {
const html = neutralizeUnsafeLinks(
'<p>a <a href="javascript:alert(1)">x</a> <a href="data:text/html,b">y</a> <a href="vbscript:z">w</a></p>',
)
expect(html).toBe('<p>a x y w</p>')
})
it('keeps safe anchors untouched', () => {
const html = neutralizeUnsafeLinks('<p><a href="/a" target="_blank">x</a></p>')
expect(html).toBe('<p><a href="/a" target="_blank">x</a></p>')
})
})
+273
View File
@@ -0,0 +1,273 @@
import { getMarkRange } from '@tiptap/core'
import type { Editor } from '@tiptap/core'
import Link from '@tiptap/extension-link'
import { Plugin } from '@tiptap/pm/state'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { isStoredHrefSafe } from '@/lib/articleLinkUrl'
export const NEW_TAB_TARGET = '_blank'
export const NEW_TAB_REL = 'noopener noreferrer'
export const LINK_FOCUS_CLASS = 'article-link-focus'
const INSIDE_FIRST_CHARACTER = 1
export interface LinkPopoverTarget {
element: HTMLElement
pos: number
}
export interface ArticleLinkOptions {
onRequestDialog: () => void
onRequestPopover: (target: LinkPopoverTarget) => void
}
export interface LinkAttributes {
href: string
target: string | null
rel: string | null
}
export interface LinkRange {
from: number
to: number
attrs: LinkAttributes
}
export function linkAttributes(href: string, newTab: boolean): LinkAttributes {
return {
href,
target: newTab ? NEW_TAB_TARGET : null,
rel: newTab ? NEW_TAB_REL : null,
}
}
function anchorFrom(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) {
return null
}
return target.closest<HTMLElement>('a[href]')
}
function linkMarkAt(doc: ProseMirrorNode, pos: number, editor: Pick<Editor, 'schema'>) {
const type = editor.schema.marks.link
if (!type) {
return null
}
const range = getMarkRange(doc.resolve(pos), type)
if (!range) {
return null
}
const node = doc.nodeAt(range.from)
const mark = node?.marks.find((candidate) => candidate.type === type)
if (!mark) {
return null
}
return {
from: range.from,
to: range.to,
attrs: {
href: String(mark.attrs.href ?? ''),
target: (mark.attrs.target as string | null) ?? null,
rel: (mark.attrs.rel as string | null) ?? null,
},
}
}
export function linkRangeAtPos(editor: Editor, pos: number): LinkRange | null {
return linkMarkAt(editor.state.doc, pos, editor)
}
export function linkRangeAtSelection(editor: Editor): LinkRange | null {
const { from, to, empty } = editor.state.selection
const found = linkMarkAt(editor.state.doc, empty ? from : from + INSIDE_FIRST_CHARACTER, editor)
if (!found || found.to < to) {
return null
}
return found
}
export function linkTouchesSelection(editor: Editor): boolean {
const { from, to, empty } = editor.state.selection
const type = editor.schema.marks.link
if (!type) {
return false
}
if (empty) {
return editor.isActive('link')
}
return editor.state.doc.rangeHasMark(from, to, type)
}
export function imageIsSelected(editor: Editor): boolean {
return editor.isActive('image')
}
export const ArticleLink = Link.extend<ArticleLinkOptions>({
addOptions() {
return {
...this.parent?.(),
openOnClick: false,
HTMLAttributes: { target: null, rel: null, class: null },
isAllowedUri: (url: string) => isStoredHrefSafe(url),
onRequestDialog: () => {},
onRequestPopover: () => {},
}
},
addAttributes() {
return {
...this.parent?.(),
target: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('target') === NEW_TAB_TARGET ? NEW_TAB_TARGET : null,
},
rel: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('target') === NEW_TAB_TARGET ? NEW_TAB_REL : null,
},
}
},
parseHTML() {
return [
{
tag: 'a[href]',
getAttrs: (element) =>
isStoredHrefSafe((element as HTMLElement).getAttribute('href') ?? '')
? null
: false,
},
]
},
addKeyboardShortcuts() {
return {
'Mod-k': () => {
this.options.onRequestDialog()
return true
},
}
},
addProseMirrorPlugins() {
return [
...(this.parent?.() ?? []),
new Plugin({
props: {
decorations: (state) => {
const type = state.schema.marks.link
if (!type || !state.selection.empty) {
return null
}
const range = getMarkRange(state.selection.$from, type)
if (!range) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: LINK_FOCUS_CLASS }),
])
},
handleClick: (view, pos, event) => {
const element = anchorFrom(event.target)
if (!element || !view.editable) {
return false
}
event.preventDefault()
this.options.onRequestPopover({ element, pos })
return false
},
handleKeyDown: (view, event) => {
const element = anchorFrom(event.target)
if (event.key !== 'Enter' || !element || !view.editable) {
return false
}
event.preventDefault()
this.options.onRequestPopover({ element, pos: view.posAtDOM(element, 0) })
return true
},
},
}),
]
},
})
export interface TextRange {
from: number
to: number
}
export interface LinkDialogDraft {
mode: 'insert' | 'edit'
text: string
href: string
newTab: boolean
range: TextRange
}
export interface LinkEdit {
text: string
href: string
newTab: boolean
}
export function textOfRange(editor: Editor, range: TextRange) {
return editor.state.doc.textBetween(range.from, range.to, ' ')
}
export function describeLinkDialog(editor: Editor): LinkDialogDraft {
const existing = linkRangeAtSelection(editor)
if (existing) {
return {
mode: 'edit',
text: textOfRange(editor, existing),
href: existing.attrs.href,
newTab: existing.attrs.target === NEW_TAB_TARGET,
range: { from: existing.from, to: existing.to },
}
}
const { from, to } = editor.state.selection
return {
mode: 'insert',
text: textOfRange(editor, { from, to }),
href: '',
newTab: false,
range: { from, to },
}
}
export function isUnchangedLinkEdit(draft: LinkDialogDraft, edit: LinkEdit) {
return (
draft.mode === 'edit' &&
edit.text === draft.text.trim() &&
edit.href === draft.href &&
edit.newTab === draft.newTab
)
}
export function applyLinkEdit(editor: Editor, draft: LinkDialogDraft, edit: LinkEdit) {
if (isUnchangedLinkEdit(draft, edit)) {
editor.commands.focus()
return
}
const attrs = linkAttributes(edit.href, edit.newTab)
const chain = editor.chain().focus().setTextSelection(draft.range)
if (edit.text === draft.text.trim()) {
chain.setMark('link', attrs)
} else {
const at = draft.range.from === draft.range.to ? draft.range.from : draft.range
chain.insertContentAt(at, {
type: 'text',
text: edit.text,
marks: [{ type: 'link', attrs }],
})
}
chain.run()
}
export function removeLinkInRange(editor: Editor, range: TextRange) {
editor
.chain()
.focus()
.setTextSelection(range)
.unsetMark('link')
.setTextSelection(range.to)
.run()
}
+39
View File
@@ -0,0 +1,39 @@
const WEB_URL = /^https?:\/\/[^\s\\/?#]+(?:[/?#]\S*)?$/i
const MAIL_URL = /^mailto:[^\s@/?#]+@[^\s@]+$/i
const SITE_PATH = /^\/(?![/\\])\S*$/
const ANCHOR = /^#\S+$/
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g
const HAS_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/
const SCHEME = /^([a-z][a-z0-9+.-]*):/i
const STORED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'ftp']
export function normalizeLinkUrl(raw: string): string | null {
const url = raw.trim()
if (HAS_CONTROL_CHARACTER.test(url)) {
return null
}
if (WEB_URL.test(url) || MAIL_URL.test(url) || SITE_PATH.test(url) || ANCHOR.test(url)) {
return url
}
return null
}
export function isStoredHrefSafe(raw: string): boolean {
const visible = raw.replace(CONTROL_CHARACTERS, '').trim().replace(/\s/g, '')
const scheme = SCHEME.exec(visible)
if (scheme === null) {
return visible !== ''
}
return STORED_SCHEMES.includes(scheme[1]!.toLowerCase())
}
export function neutralizeUnsafeLinks(html: string): string {
const document = new DOMParser().parseFromString(html, 'text/html')
for (const anchor of document.body.querySelectorAll('a')) {
if (isStoredHrefSafe(anchor.getAttribute('href') ?? '')) {
continue
}
anchor.replaceWith(...anchor.childNodes)
}
return document.body.innerHTML
}
+46 -5
View File
@@ -11,6 +11,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
import EditorStatusBar from '@/components/EditorStatusBar.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue'
@@ -90,6 +91,25 @@ async function loadArticle() {
}
const counts = computed(() => bodyCounts(draft.body.value))
const previewBody = computed(() => neutralizeUnsafeLinks(draft.body.value))
const countText = computed(() =>
t('{0} words · {1} characters', [
counts.value.words.toLocaleString('en-US'),
counts.value.characters.toLocaleString('en-US'),
]),
)
const overflowItems = computed(() => [
[
{
icon: 'i-lucide-trash-2',
color: 'error' as const,
label: t('Delete article'),
onSelect: () => {
handleDelete()
},
},
],
])
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
@@ -403,7 +423,24 @@ onBeforeRouteLeave(async () => {
</script>
<template>
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
<DashboardShell
:mobile-title="t('Edit article')"
back-to="/article"
:back-label="t('Back to Articles')"
full-bleed
>
<template v-if="loadState === 'loaded'" #navbar-right>
<UDropdownMenu :items="overflowItems">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
class="size-11 justify-center lg:hidden"
:ui="{ leadingIcon: 'size-5' }"
:aria-label="t('More actions')"
/>
</UDropdownMenu>
</template>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
@@ -467,17 +504,19 @@ onBeforeRouteLeave(async () => {
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
class="hidden size-8 justify-center text-toned lg:inline-flex"
:aria-label="t('Back to Articles')"
@click="goBack"
/>
<div class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5">
<div
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
:title="t('The title can\'t be changed after creation.')"
@@ -491,6 +530,7 @@ onBeforeRouteLeave(async () => {
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
@@ -545,7 +585,7 @@ onBeforeRouteLeave(async () => {
<h2 class="text-2xl font-semibold text-highlighted">
{{ article.title }}
</h2>
<div v-html="draft.body.value" />
<div v-html="previewBody" />
</div>
</div>
<div
@@ -566,6 +606,7 @@ onBeforeRouteLeave(async () => {
>
<template #footer>
<EditorStatusBar
class="max-lg:hidden"
:words="counts.words"
:characters="counts.characters"
:state="draft.state.value"
+217 -1
View File
@@ -396,6 +396,63 @@ async function markVeryLongDirty(page: Page) {
await focusBoldSample(page)
}
const linkLongUrl =
'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf'
const linkSentence =
'<p>We also check the <a href="/articles/winter-checklist" target="_blank" rel="noopener noreferrer">timber windows</a> before any tiling starts.</p>'
const linkedBody = bathroomBody.replace('</p><h2>', `</p>${linkSentence}<h2>`)
const linkImageSrc = '/static/uploadedImages/blank.svg'
const linkedImageBody = `${linkedBody}<img src="${linkImageSrc}" alt="" data-image-id="1">`
const MOBILE_MAX_WIDTH = 640
const TOOLTIP_SETTLE_MS = 700
function linkedApi(body = linkedBody) {
return editorApi({
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
})
}
function isNarrow(page: Page) {
return (page.viewportSize()?.width ?? MOBILE_MAX_WIDTH) < MOBILE_MAX_WIDTH
}
function linkButton(page: Page) {
return page.getByRole('button', { name: 'Insert link', exact: true }).first()
}
function linkDialog(page: Page) {
return page.locator('[data-presentation]')
}
async function typeIntoLinkField(page: Page, label: string, value: string) {
await linkDialog(page).getByLabel(label, { exact: true }).fill(value)
}
async function openLinkDialogOnWord(page: Page) {
await markDirty(page)
await page.getByText('choose yourself').dblclick()
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
}
async function openLinkDialogOnLink(page: Page) {
await markDirty(page)
await page.locator('.article-content a').click()
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await linkDialog(page).waitFor()
}
async function dirtyWithLinks(page: Page) {
await focusBoldSample(page)
await markDirty(page)
await focusBoldSample(page)
}
async function useTouchPointer(page: Page) {
const session = await page.context().newCDPSession(page)
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
@@ -4946,7 +5003,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.getByRole('button', { name: 'Back to Articles' }).click()
await page.locator('[aria-label="Back to Articles"]:visible').click()
await page.getByRole('alertdialog').waitFor()
},
},
@@ -4975,6 +5032,165 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/article/9999': { status: 404 },
},
},
'Link: create from selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link: create with no selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
},
},
'Link: edit existing': {
path: '/article/12/edit',
api: linkedApi(),
steps: openLinkDialogOnLink,
},
'Link: invalid URL': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await typeIntoLinkField(page, 'URL', 'www.example')
await page.keyboard.press('Tab')
await linkDialog(page).getByText('Enter a valid URL').waitFor()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: empty text': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
await typeIntoLinkField(page, 'URL', '#measuring')
await linkDialog(page).getByRole('button', { name: 'Insert link' }).click()
await linkDialog(page).getByText('Enter the link text.').waitFor()
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: long URL': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await openLinkDialogOnLink(page)
await typeIntoLinkField(page, 'Link text', 'planning guidance')
await typeIntoLinkField(page, 'URL', linkLongUrl)
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link button: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Link button: hover and tooltip': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await linkButton(page).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
},
},
'Link button: focus-visible': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await focusBoldSample(page)
await markDirty(page)
await linkButton(page).focus()
},
},
'Link button: active (cursor in a link)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').click()
await page.keyboard.press('Escape')
},
},
'Link button: disabled': {
path: '/article/12/edit',
api: linkedApi(linkedImageBody),
steps: async (page) => {
await dirtyWithLinks(page)
await page.getByTestId('article-editor-image').click({ force: true })
await page.addStyleTag({
content: '[data-testid="article-editor-image"] { display: none; }',
})
},
},
'Link popover and sheet': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await useTouchPointer(page)
}
await page.locator('.article-content a').click()
await page.locator('[aria-label="Link"]').waitFor()
},
},
'Rendered link: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Rendered link: hover': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').hover()
},
},
'Rendered link: focus': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await markDirty(page)
await page.getByText('before any tiling starts').click()
await page.keyboard.press('Home')
for (let step = 0; step < 21; step += 1) {
await page.keyboard.press('ArrowRight')
}
},
},
'Rendered link: selected': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').selectText()
},
},
'Toolbar overflow (390 px)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await page
.locator('.article-toolbar')
.evaluate((toolbar) => toolbar.scrollTo({ left: toolbar.scrollWidth }))
}
},
},
'Upload: choose': {
path: '/article/12/edit',
now: editorNow,