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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in new issue
Block a user