mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add rich text description formatting bar
Replace the plain textarea for book descriptions with a Tiptap-based rich text editor in the metadata edit tab. Include HTML sanitization utilities to ensure safe rendering of user-provided descriptions. Closes #460
This commit is contained in:
@@ -43,6 +43,10 @@
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.29",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@tiptap/vue-3": "^3.27.1",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
@@ -18,6 +18,7 @@ import ChipInput from '@/components/ui/ChipInput.vue'
|
||||
import CoverEditorPanel from './CoverEditorPanel.vue'
|
||||
import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
|
||||
import MetadataFieldLabel from './MetadataFieldLabel.vue'
|
||||
import RichDescriptionEditor from './RichDescriptionEditor.vue'
|
||||
import SeriesMembershipEditor from './SeriesMembershipEditor.vue'
|
||||
import WriteAndRenameResultPanel from '../WriteAndRenameResultPanel.vue'
|
||||
import type { MetadataPatch } from '../../../composables/useMetadataDiff'
|
||||
@@ -1422,12 +1423,7 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
multiline
|
||||
@toggle="handleLockToggle"
|
||||
>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="6"
|
||||
class="w-full resize-y rounded-lg border border-input bg-background px-3 py-2 pr-12 text-sm outline-none focus:ring-1 focus:ring-ring transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isLocked('description')"
|
||||
/>
|
||||
<RichDescriptionEditor v-model="form.description" :disabled="isLocked('description')" />
|
||||
</MetadataFieldLabel>
|
||||
|
||||
<!-- Mobile: sticky Save/Cancel bar -->
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
Bold,
|
||||
Check,
|
||||
Eraser,
|
||||
Eye,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
PencilLine,
|
||||
Quote,
|
||||
Redo2,
|
||||
Strikethrough,
|
||||
Underline,
|
||||
Undo2,
|
||||
Unlink,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { normalizeDescriptionHtml } from '@/features/book/lib/description-html'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
const linkPanelOpen = ref(false)
|
||||
const linkUrl = ref('')
|
||||
const linkError = ref<string | null>(null)
|
||||
const previewOpen = ref(false)
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue ?? '',
|
||||
editable: !props.disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
autolink: false,
|
||||
defaultProtocol: 'https',
|
||||
linkOnPaste: false,
|
||||
openOnClick: false,
|
||||
protocols: ['http', 'https', 'mailto'],
|
||||
HTMLAttributes: {
|
||||
rel: null,
|
||||
target: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'min-h-[10rem] w-full outline-none px-3 py-2 text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
emit('update:modelValue', normalizeDescriptionHtml(instance.getHTML()))
|
||||
},
|
||||
})
|
||||
|
||||
const canEdit = computed(() => !!editor.value && !props.disabled)
|
||||
const isBoldActive = computed(() => editor.value?.isActive('bold') ?? false)
|
||||
const isItalicActive = computed(() => editor.value?.isActive('italic') ?? false)
|
||||
const isUnderlineActive = computed(() => editor.value?.isActive('underline') ?? false)
|
||||
const isStrikeActive = computed(() => editor.value?.isActive('strike') ?? false)
|
||||
const isBulletListActive = computed(() => editor.value?.isActive('bulletList') ?? false)
|
||||
const isOrderedListActive = computed(() => editor.value?.isActive('orderedList') ?? false)
|
||||
const isBlockquoteActive = computed(() => editor.value?.isActive('blockquote') ?? false)
|
||||
const isLinkActive = computed(() => editor.value?.isActive('link') ?? false)
|
||||
const canUndo = computed(() => canEdit.value && (editor.value?.can().undo() ?? false))
|
||||
const canRedo = computed(() => canEdit.value && (editor.value?.can().redo() ?? false))
|
||||
const canIndentList = computed(() => canEdit.value && (editor.value?.can().sinkListItem('listItem') ?? false))
|
||||
const canOutdentList = computed(() => canEdit.value && (editor.value?.can().liftListItem('listItem') ?? false))
|
||||
const previewHtml = computed(() => normalizeDescriptionHtml(editor.value?.getHTML() ?? props.modelValue ?? '') ?? '')
|
||||
|
||||
const boldButtonClass = computed(() => buttonClass(isBoldActive.value))
|
||||
const italicButtonClass = computed(() => buttonClass(isItalicActive.value))
|
||||
const underlineButtonClass = computed(() => buttonClass(isUnderlineActive.value))
|
||||
const strikeButtonClass = computed(() => buttonClass(isStrikeActive.value))
|
||||
const bulletListButtonClass = computed(() => buttonClass(isBulletListActive.value))
|
||||
const orderedListButtonClass = computed(() => buttonClass(isOrderedListActive.value))
|
||||
const blockquoteButtonClass = computed(() => buttonClass(isBlockquoteActive.value))
|
||||
const linkButtonClass = computed(() => buttonClass(isLinkActive.value || linkPanelOpen.value))
|
||||
const previewButtonClass = computed(() => buttonClass(previewOpen.value))
|
||||
const plainButtonClass = computed(() => buttonClass(false))
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
editor.value?.setEditable(!disabled)
|
||||
if (disabled) closeLinkPanel()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const instance = editor.value
|
||||
if (!instance) return
|
||||
|
||||
const current = normalizeDescriptionHtml(instance.getHTML())
|
||||
const next = normalizeDescriptionHtml(value)
|
||||
if (current === next) return
|
||||
|
||||
instance.commands.setContent(value ?? '', { emitUpdate: false })
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
function buttonClass(isActive: boolean) {
|
||||
return [
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40',
|
||||
isActive ? 'border-primary bg-primary text-primary-foreground' : 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
]
|
||||
}
|
||||
|
||||
function toggleBold() {
|
||||
editor.value?.chain().focus().toggleBold().run()
|
||||
}
|
||||
|
||||
function toggleItalic() {
|
||||
editor.value?.chain().focus().toggleItalic().run()
|
||||
}
|
||||
|
||||
function toggleUnderline() {
|
||||
editor.value?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
|
||||
function toggleStrike() {
|
||||
editor.value?.chain().focus().toggleStrike().run()
|
||||
}
|
||||
|
||||
function toggleBulletList() {
|
||||
editor.value?.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
|
||||
function toggleOrderedList() {
|
||||
editor.value?.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
|
||||
function toggleBlockquote() {
|
||||
editor.value?.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
|
||||
function indentList() {
|
||||
editor.value?.chain().focus().sinkListItem('listItem').run()
|
||||
}
|
||||
|
||||
function outdentList() {
|
||||
editor.value?.chain().focus().liftListItem('listItem').run()
|
||||
}
|
||||
|
||||
function undo() {
|
||||
editor.value?.chain().focus().undo().run()
|
||||
}
|
||||
|
||||
function redo() {
|
||||
editor.value?.chain().focus().redo().run()
|
||||
}
|
||||
|
||||
function clearFormatting() {
|
||||
editor.value?.chain().focus().unsetAllMarks().clearNodes().run()
|
||||
closeLinkPanel()
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
previewOpen.value = !previewOpen.value
|
||||
closeLinkPanel()
|
||||
}
|
||||
|
||||
function toggleLinkPanel() {
|
||||
if (!canEdit.value) return
|
||||
if (linkPanelOpen.value) {
|
||||
closeLinkPanel()
|
||||
return
|
||||
}
|
||||
|
||||
const href = editor.value?.getAttributes('link')['href']
|
||||
linkUrl.value = typeof href === 'string' ? href : ''
|
||||
linkError.value = null
|
||||
linkPanelOpen.value = true
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
const instance = editor.value
|
||||
if (!instance) return
|
||||
|
||||
const href = normalizeLinkUrl(linkUrl.value)
|
||||
if (!href) {
|
||||
instance.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
closeLinkPanel()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedLinkUrl(href)) {
|
||||
linkError.value = 'Use http, https, or mailto.'
|
||||
return
|
||||
}
|
||||
|
||||
instance.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||
closeLinkPanel()
|
||||
}
|
||||
|
||||
function removeLink() {
|
||||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
closeLinkPanel()
|
||||
}
|
||||
|
||||
function closeLinkPanel() {
|
||||
linkPanelOpen.value = false
|
||||
linkUrl.value = ''
|
||||
linkError.value = null
|
||||
}
|
||||
|
||||
function normalizeLinkUrl(raw: string): string {
|
||||
const value = raw.trim()
|
||||
if (!value) return ''
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(value)) return value
|
||||
return `https://${value}`
|
||||
}
|
||||
|
||||
function isAllowedLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-lg border border-input bg-background transition-shadow focus-within:ring-1 focus-within:ring-ring">
|
||||
<div class="flex min-h-10 flex-wrap items-center gap-1 border-b border-border bg-muted/30 px-1.5 py-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="boldButtonClass" :disabled="!canEdit" aria-label="Bold" @click="toggleBold">
|
||||
<Bold class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bold</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="italicButtonClass" :disabled="!canEdit" aria-label="Italic" @click="toggleItalic">
|
||||
<Italic class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Italic</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="underlineButtonClass" :disabled="!canEdit" aria-label="Underline" @click="toggleUnderline">
|
||||
<Underline class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Underline</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="strikeButtonClass" :disabled="!canEdit" aria-label="Strikethrough" @click="toggleStrike">
|
||||
<Strikethrough class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Strikethrough</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mx-0.5 h-5 w-px bg-border" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="bulletListButtonClass" :disabled="!canEdit" aria-label="Bullet list" @click="toggleBulletList">
|
||||
<List class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bullet list</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="orderedListButtonClass" :disabled="!canEdit" aria-label="Numbered list" @click="toggleOrderedList">
|
||||
<ListOrdered class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Numbered list</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canOutdentList" aria-label="Outdent list item" @click="outdentList">
|
||||
<IndentDecrease class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Outdent</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canIndentList" aria-label="Indent list item" @click="indentList">
|
||||
<IndentIncrease class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Indent</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="blockquoteButtonClass" :disabled="!canEdit" aria-label="Quote" @click="toggleBlockquote">
|
||||
<Quote class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Quote</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mx-0.5 h-5 w-px bg-border" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="linkButtonClass" :disabled="!canEdit" aria-label="Edit link" @click="toggleLinkPanel">
|
||||
<LinkIcon class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canEdit || !isLinkActive" aria-label="Remove link" @click="removeLink">
|
||||
<Unlink class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mx-0.5 h-5 w-px bg-border" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canUndo" aria-label="Undo" @click="undo">
|
||||
<Undo2 class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canRedo" aria-label="Redo" @click="redo">
|
||||
<Redo2 class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Redo</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="plainButtonClass" :disabled="!canEdit" aria-label="Clear formatting" @click="clearFormatting">
|
||||
<Eraser class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear formatting</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mx-0.5 h-5 w-px bg-border" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" :class="previewButtonClass" aria-label="Preview description" @click="togglePreview">
|
||||
<PencilLine v-if="previewOpen" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ previewOpen ? 'Edit' : 'Preview' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="linkPanelOpen" class="flex flex-wrap items-start gap-2 border-b border-border bg-card px-2 py-2">
|
||||
<label class="min-w-0 flex-1">
|
||||
<span class="sr-only">Link URL</span>
|
||||
<input
|
||||
v-model="linkUrl"
|
||||
type="url"
|
||||
class="h-8 w-full rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="https://example.com"
|
||||
@keydown.enter.prevent="applyLink"
|
||||
@keydown.esc.prevent="closeLinkPanel"
|
||||
/>
|
||||
<span v-if="linkError" class="mt-1 block text-xs text-destructive">{{ linkError }}</span>
|
||||
</label>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border border-input hover:bg-muted"
|
||||
aria-label="Apply link"
|
||||
@click="applyLink"
|
||||
>
|
||||
<Check class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Apply</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border border-input hover:bg-muted"
|
||||
aria-label="Cancel link"
|
||||
@click="closeLinkPanel"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Cancel</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewOpen"
|
||||
class="min-h-[10rem] px-3 py-2 text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by sanitizeDescriptionHtml -->
|
||||
<div v-if="previewHtml" v-html="previewHtml"></div>
|
||||
<p v-else class="italic text-muted-foreground">No description available.</p>
|
||||
</div>
|
||||
<EditorContent v-else-if="editor" :editor="editor" :class="{ 'pointer-events-none opacity-50': disabled }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,7 @@ import { useSafeHtml } from './useSafeHtml'
|
||||
|
||||
vi.mock('dompurify', () => ({
|
||||
default: {
|
||||
sanitize: vi.fn<(input: string, config: { ALLOWED_TAGS: string[] }) => string>((input: string, config: { ALLOWED_TAGS: string[] }) => {
|
||||
sanitize: vi.fn<(input: string, config: { ALLOWED_ATTR: string[]; ALLOWED_TAGS: string[] }) => string>((input: string, config) => {
|
||||
if (!config?.ALLOWED_TAGS) return ''
|
||||
// Simulate DOMPurify by stripping disallowed tags and dangerous attributes
|
||||
return input
|
||||
@@ -42,7 +42,8 @@ describe('useSafeHtml', () => {
|
||||
const html = useSafeHtml(() => '<b>Hello</b>')
|
||||
const _ = html.value
|
||||
expect(sanitizeMock).toHaveBeenCalledWith('<b>Hello</b>', {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a'],
|
||||
ALLOWED_ATTR: ['href'],
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 's', 'u', 'p', 'br', 'ul', 'ol', 'li', 'a', 'blockquote'],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const SAFE_TAGS = ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a']
|
||||
import { sanitizeDescriptionHtml } from '@/features/book/lib/description-html'
|
||||
|
||||
export function useSafeHtml(rawHtml: () => string | null | undefined) {
|
||||
return computed(() => {
|
||||
const content = rawHtml()
|
||||
if (!content) return ''
|
||||
return DOMPurify.sanitize(content, { ALLOWED_TAGS: SAFE_TAGS })
|
||||
return sanitizeDescriptionHtml(content)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeDescriptionHtml, sanitizeDescriptionHtml } from './description-html'
|
||||
|
||||
describe('description-html', () => {
|
||||
it('keeps supported description tags and link hrefs', () => {
|
||||
const html = sanitizeDescriptionHtml(
|
||||
'<blockquote><p>Hello <strong>world</strong> <u>underlined</u> <s>removed</s> <a href="https://bookorbit.app">BookOrbit</a></p></blockquote>',
|
||||
)
|
||||
|
||||
expect(html).toBe(
|
||||
'<blockquote><p>Hello <strong>world</strong> <u>underlined</u> <s>removed</s> <a href="https://bookorbit.app">BookOrbit</a></p></blockquote>',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes unsupported tags and unsafe attributes', () => {
|
||||
const html = sanitizeDescriptionHtml('<p onclick="alert(1)">Hello</p><script>alert(1)</script><img src="x">')
|
||||
|
||||
expect(html).toBe('<p>Hello</p>')
|
||||
})
|
||||
|
||||
it('normalizes empty editor output to null', () => {
|
||||
expect(normalizeDescriptionHtml('<p><br></p>')).toBeNull()
|
||||
expect(normalizeDescriptionHtml('<ul><li></li></ul>')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns sanitized HTML for visible content', () => {
|
||||
expect(normalizeDescriptionHtml('<p><em>Visible</em></p>')).toBe('<p><em>Visible</em></p>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export const DESCRIPTION_HTML_TAGS = ['b', 'i', 'em', 'strong', 's', 'u', 'p', 'br', 'ul', 'ol', 'li', 'a', 'blockquote'] as const
|
||||
export const DESCRIPTION_HTML_ATTRS = ['href'] as const
|
||||
|
||||
export function sanitizeDescriptionHtml(content: string): string {
|
||||
return DOMPurify.sanitize(content, {
|
||||
ALLOWED_TAGS: [...DESCRIPTION_HTML_TAGS],
|
||||
ALLOWED_ATTR: [...DESCRIPTION_HTML_ATTRS],
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeDescriptionHtml(content: string | null | undefined): string | null {
|
||||
const sanitized = sanitizeDescriptionHtml(content ?? '')
|
||||
return hasVisibleDescriptionText(sanitized) ? sanitized : null
|
||||
}
|
||||
|
||||
function hasVisibleDescriptionText(content: string): boolean {
|
||||
const text = content
|
||||
.replace(/<br\s*\/?>/gi, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.trim()
|
||||
|
||||
return text.length > 0
|
||||
}
|
||||
Generated
+592
@@ -74,6 +74,18 @@ importers:
|
||||
"@tanstack/vue-virtual":
|
||||
specifier: ^3.13.29
|
||||
version: 3.13.29(vue@3.5.38(typescript@6.0.3))
|
||||
"@tiptap/extension-link":
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm":
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
"@tiptap/starter-kit":
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
"@tiptap/vue-3":
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.38(typescript@6.0.3))
|
||||
"@types/howler":
|
||||
specifier: ^2.2.13
|
||||
version: 2.2.13
|
||||
@@ -5541,6 +5553,237 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
"@tiptap/core@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-blockquote@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-bold@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-bubble-menu@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-bullet-list@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extension-list": 3.27.1
|
||||
|
||||
"@tiptap/extension-code-block@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-code@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-document@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-dropcursor@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extensions": 3.27.1
|
||||
|
||||
"@tiptap/extension-floating-menu@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@floating-ui/dom": ^1.0.0
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-gapcursor@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extensions": 3.27.1
|
||||
|
||||
"@tiptap/extension-hard-break@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-heading@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-horizontal-rule@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-italic@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-link@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-list-item@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extension-list": 3.27.1
|
||||
|
||||
"@tiptap/extension-list-keymap@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extension-list": 3.27.1
|
||||
|
||||
"@tiptap/extension-list@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-ordered-list@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/extension-list": 3.27.1
|
||||
|
||||
"@tiptap/extension-paragraph@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-strike@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-text@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extension-underline@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
|
||||
"@tiptap/extensions@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/pm@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==,
|
||||
}
|
||||
|
||||
"@tiptap/starter-kit@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==,
|
||||
}
|
||||
|
||||
"@tiptap/vue-3@3.27.1":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-o5GB6hfUnyf9sCB306rHWmaIYRL+02ROX657EkuY8tEWKHMTuMjHWl2AqHMP47wz0W9DaMOJLvcPpYdAEKq3Mw==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@floating-ui/dom": ^1.0.0
|
||||
"@tiptap/core": 3.27.1
|
||||
"@tiptap/pm": 3.27.1
|
||||
vue: ^3.0.0
|
||||
|
||||
"@tokenizer/inflate@0.4.1":
|
||||
resolution:
|
||||
{
|
||||
@@ -10455,6 +10698,12 @@ packages:
|
||||
integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==,
|
||||
}
|
||||
|
||||
linkifyjs@4.3.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==,
|
||||
}
|
||||
|
||||
lint-staged@17.0.8:
|
||||
resolution:
|
||||
{
|
||||
@@ -11323,6 +11572,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==,
|
||||
}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution:
|
||||
{
|
||||
@@ -11960,6 +12215,84 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.6.0" }
|
||||
|
||||
prosemirror-changeset@2.4.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==,
|
||||
}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==,
|
||||
}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==,
|
||||
}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==,
|
||||
}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==,
|
||||
}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==,
|
||||
}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==,
|
||||
}
|
||||
|
||||
prosemirror-model@1.25.9:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==,
|
||||
}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==,
|
||||
}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==,
|
||||
}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==,
|
||||
}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==,
|
||||
}
|
||||
|
||||
prosemirror-view@1.41.9:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==,
|
||||
}
|
||||
|
||||
proto-list@1.2.4:
|
||||
resolution:
|
||||
{
|
||||
@@ -12302,6 +12635,12 @@ packages:
|
||||
engines: { node: ">=18.0.0", npm: ">=8.0.0" }
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==,
|
||||
}
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -14159,6 +14498,12 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==,
|
||||
}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -17778,6 +18123,171 @@ snapshots:
|
||||
"@tanstack/virtual-core": 3.17.1
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
|
||||
"@tiptap/core@3.27.1(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-blockquote@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-bold@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@floating-ui/dom": 1.7.6
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
optional: true
|
||||
|
||||
"@tiptap/extension-bullet-list@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extension-list": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-code@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-dropcursor@3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extensions": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@floating-ui/dom": 1.7.6
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
optional: true
|
||||
|
||||
"@tiptap/extension-gapcursor@3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extensions": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-hard-break@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-heading@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-horizontal-rule@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-italic@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
linkifyjs: 4.3.3
|
||||
|
||||
"@tiptap/extension-list-item@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extension-list": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-list-keymap@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extension-list": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/extension-list": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-paragraph@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-strike@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-text@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extension-underline@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/pm@3.27.1":
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
|
||||
"@tiptap/starter-kit@3.27.1":
|
||||
dependencies:
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-blockquote": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-bold": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-bullet-list": 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-code": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-code-block": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-document": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-dropcursor": 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-gapcursor": 3.27.1(@tiptap/extensions@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-hard-break": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-heading": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-horizontal-rule": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-italic": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-link": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-list": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-list-item": 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-list-keymap": 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-ordered-list": 3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-paragraph": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-strike": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-text": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extension-underline": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))
|
||||
"@tiptap/extensions": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
|
||||
"@tiptap/vue-3@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(vue@3.5.38(typescript@6.0.3))":
|
||||
dependencies:
|
||||
"@floating-ui/dom": 1.7.6
|
||||
"@tiptap/core": 3.27.1(@tiptap/pm@3.27.1)
|
||||
"@tiptap/pm": 3.27.1
|
||||
vue: 3.5.38(typescript@6.0.3)
|
||||
optionalDependencies:
|
||||
"@tiptap/extension-bubble-menu": 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
"@tiptap/extension-floating-menu": 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)
|
||||
|
||||
"@tokenizer/inflate@0.4.1":
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -20844,6 +21354,8 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkifyjs@4.3.3: {}
|
||||
|
||||
lint-staged@17.0.8:
|
||||
dependencies:
|
||||
listr2: 10.2.1
|
||||
@@ -21256,6 +21768,8 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wcwidth: 1.0.1
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -21608,6 +22122,80 @@ snapshots:
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prosemirror-changeset@2.4.1:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.9
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-model@1.25.9:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
|
||||
prosemirror-view@1.41.9:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
proto-list@1.2.4: {}
|
||||
|
||||
pump@3.0.4:
|
||||
@@ -21868,6 +22456,8 @@ snapshots:
|
||||
"@rollup/rollup-win32-x64-msvc": 4.62.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
@@ -23089,6 +23679,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
Reference in New Issue
Block a user