refactor: migrate to nuxt compatibilityVersion: 4 (#3298)
This commit is contained in:
parent
46e4433e1c
commit
a3fbc056a9
342 changed files with 1200 additions and 2932 deletions
116
app/composables/tiptap/custom-emoji.ts
Normal file
116
app/composables/tiptap/custom-emoji.ts
Normal file
|
@ -0,0 +1,116 @@
|
|||
import {
|
||||
mergeAttributes,
|
||||
Node,
|
||||
nodeInputRule,
|
||||
} from '@tiptap/core'
|
||||
|
||||
export interface EmojiOptions {
|
||||
inline: boolean
|
||||
allowBase64: boolean
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
emoji: {
|
||||
/**
|
||||
* Insert a custom emoji.
|
||||
*/
|
||||
insertCustomEmoji: (options: {
|
||||
src: string
|
||||
alt?: string
|
||||
title?: string
|
||||
}) => ReturnType
|
||||
/**
|
||||
* Insert a emoji.
|
||||
*/
|
||||
insertEmoji: (native: string) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputRegex = /(?:^|\s)(!\[(.+|:?)\]\((\S+)(?:\s+["'](\S+)["'])?\))$/
|
||||
|
||||
export const TiptapPluginCustomEmoji = Node.create<EmojiOptions>({
|
||||
name: 'custom-emoji',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
inline() {
|
||||
return this.options.inline
|
||||
},
|
||||
|
||||
group() {
|
||||
return this.options.inline ? 'inline' : 'block'
|
||||
},
|
||||
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
'src': {
|
||||
default: null,
|
||||
},
|
||||
'alt': {
|
||||
default: null,
|
||||
},
|
||||
'title': {
|
||||
default: null,
|
||||
},
|
||||
'width': {
|
||||
default: null,
|
||||
},
|
||||
'height': {
|
||||
default: null,
|
||||
},
|
||||
'data-emoji-id': {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: this.options.allowBase64
|
||||
? 'img[src]'
|
||||
: 'img[src]:not([src^="data:"])',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertCustomEmoji: options => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [,, alt, src, title] = match
|
||||
|
||||
return { src, alt, title }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
120
app/composables/tiptap/emoji.ts
Normal file
120
app/composables/tiptap/emoji.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import type { ExtendedRegExpMatchArray, InputRuleFinder, nodeInputRule } from '@tiptap/core'
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import {
|
||||
callOrReturn,
|
||||
InputRule,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
nodePasteRule,
|
||||
} from '@tiptap/core'
|
||||
import { emojiRegEx, getEmojiAttributes } from '~~/config/emojis'
|
||||
|
||||
function wrapHandler<T extends (...args: any[]) => any>(handler: T): T {
|
||||
return <T>((...args: any[]) => {
|
||||
try {
|
||||
return handler(...args)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createEmojiRule<NR extends typeof nodeInputRule | typeof nodePasteRule>(
|
||||
nodeRule: NR,
|
||||
type: Parameters<NR>[0]['type'],
|
||||
): ReturnType<NR>[] {
|
||||
const rule = nodeRule({
|
||||
find: emojiRegEx as RegExp,
|
||||
type,
|
||||
getAttributes: (match: ExtendedRegExpMatchArray) => {
|
||||
const [native] = match
|
||||
return getEmojiAttributes(native)
|
||||
},
|
||||
}) as ReturnType<NR>
|
||||
|
||||
// Error catch for unsupported emoji
|
||||
rule.handler = wrapHandler(rule.handler.bind(rule))
|
||||
|
||||
return [rule]
|
||||
}
|
||||
|
||||
export const TiptapPluginEmoji = Node.create({
|
||||
name: 'em-emoji',
|
||||
|
||||
inline: () => true,
|
||||
group: () => 'inline',
|
||||
draggable: false,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'img.iconify-emoji',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
class: {
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
renderHTML(args) {
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, args.HTMLAttributes)]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertEmoji: code => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: getEmojiAttributes(code),
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
function emojiInputRule(config: {
|
||||
find: InputRuleFinder
|
||||
type: NodeType
|
||||
getAttributes?:
|
||||
| Record<string, any>
|
||||
| ((match: ExtendedRegExpMatchArray) => Record<string, any>)
|
||||
| false
|
||||
| null
|
||||
}) {
|
||||
return new InputRule({
|
||||
find: config.find,
|
||||
handler: ({ state, range, match }) => {
|
||||
const attributes = callOrReturn(config.getAttributes, undefined, match) || {}
|
||||
const { tr } = state
|
||||
const start = range.from
|
||||
const end = range.to
|
||||
|
||||
tr.insert(start, config.type.create(attributes)).delete(
|
||||
tr.mapping.map(start),
|
||||
tr.mapping.map(end),
|
||||
)
|
||||
|
||||
tr.scrollIntoView()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return createEmojiRule(emojiInputRule, this.type)
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return createEmojiRule(nodePasteRule, this.type)
|
||||
},
|
||||
})
|
23
app/composables/tiptap/shiki-parser.ts
Normal file
23
app/composables/tiptap/shiki-parser.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { Parser } from 'prosemirror-highlight/shiki'
|
||||
import type { BuiltinLanguage } from 'shiki'
|
||||
import { createParser } from 'prosemirror-highlight/shiki'
|
||||
|
||||
let parser: Parser | undefined
|
||||
|
||||
export const shikiParser: Parser = (options) => {
|
||||
const lang = options.language ?? 'text'
|
||||
|
||||
// Register the language if it's not yet registered
|
||||
const { highlighter, promise } = useHighlighter(lang as BuiltinLanguage)
|
||||
|
||||
// If the highlighter or the language is not available, return a promise that
|
||||
// will resolve when it's ready. When the promise resolves, the editor will
|
||||
// re-parse the code block.
|
||||
if (!highlighter)
|
||||
return promise ?? []
|
||||
|
||||
if (!parser)
|
||||
parser = createParser(highlighter)
|
||||
|
||||
return parser(options)
|
||||
}
|
25
app/composables/tiptap/shiki.ts
Normal file
25
app/composables/tiptap/shiki.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import CodeBlock from '@tiptap/extension-code-block'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
||||
|
||||
import { createHighlightPlugin } from 'prosemirror-highlight'
|
||||
import TiptapCodeBlock from '~/components/tiptap/TiptapCodeBlock.vue'
|
||||
import { shikiParser } from './shiki-parser'
|
||||
|
||||
export const TiptapPluginCodeBlockShiki = CodeBlock.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
defaultLanguage: null,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
createHighlightPlugin({ parser: shikiParser, nodeTypes: ['codeBlock'] }),
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(TiptapCodeBlock)
|
||||
},
|
||||
})
|
154
app/composables/tiptap/suggestion.ts
Normal file
154
app/composables/tiptap/suggestion.ts
Normal file
|
@ -0,0 +1,154 @@
|
|||
import type { Emoji, EmojiMartData } from '@emoji-mart/data'
|
||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||
import type { mastodon } from 'masto'
|
||||
import type { GetReferenceClientRect, Instance } from 'tippy.js'
|
||||
import type { Component } from 'vue'
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import { PluginKey } from 'prosemirror-state'
|
||||
import tippy from 'tippy.js'
|
||||
import TiptapEmojiList from '~/components/tiptap/TiptapEmojiList.vue'
|
||||
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
|
||||
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
||||
import { currentCustomEmojis, updateCustomEmojis } from '~/composables/emojis'
|
||||
|
||||
export type { Emoji }
|
||||
|
||||
export type CustomEmoji = (mastodon.v1.CustomEmoji & { custom: true })
|
||||
export function isCustomEmoji(emoji: CustomEmoji | Emoji): emoji is CustomEmoji {
|
||||
return !!(emoji as CustomEmoji).custom
|
||||
}
|
||||
|
||||
export const TiptapMentionSuggestion: Partial<SuggestionOptions> = import.meta.server
|
||||
? {}
|
||||
: {
|
||||
pluginKey: new PluginKey('mention'),
|
||||
char: '@',
|
||||
async items({ query }) {
|
||||
if (query.length === 0)
|
||||
return []
|
||||
|
||||
const paginator = useMastoClient().v2.search.list({ q: query, type: 'accounts', limit: 25, resolve: true })
|
||||
return (await paginator.next()).value?.accounts ?? []
|
||||
},
|
||||
render: createSuggestionRenderer(TiptapMentionList),
|
||||
}
|
||||
|
||||
export const TiptapHashtagSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('hashtag'),
|
||||
char: '#',
|
||||
async items({ query }) {
|
||||
if (query.length === 0)
|
||||
return []
|
||||
|
||||
const paginator = useMastoClient().v2.search.list({
|
||||
q: query,
|
||||
type: 'hashtags',
|
||||
limit: 25,
|
||||
resolve: false,
|
||||
excludeUnreviewed: true,
|
||||
})
|
||||
return (await paginator.next()).value?.hashtags ?? []
|
||||
},
|
||||
render: createSuggestionRenderer(TiptapHashtagList),
|
||||
}
|
||||
|
||||
export const TiptapEmojiSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('emoji'),
|
||||
char: ':',
|
||||
async items({ query }): Promise<(CustomEmoji | Emoji)[]> {
|
||||
if (import.meta.server || query.length === 0)
|
||||
return []
|
||||
|
||||
if (currentCustomEmojis.value.emojis.length === 0)
|
||||
await updateCustomEmojis()
|
||||
|
||||
const lowerCaseQuery = query.toLowerCase()
|
||||
|
||||
const { data } = await useAsyncData<EmojiMartData>('emoji-data', () => import('@emoji-mart/data').then(r => r.default as EmojiMartData))
|
||||
const emojis: Emoji[] = Object.values(data.value?.emojis || []).filter(({ id }) => id.toLowerCase().startsWith(lowerCaseQuery))
|
||||
|
||||
const customEmojis: CustomEmoji[] = currentCustomEmojis.value.emojis
|
||||
.filter(emoji => emoji.shortcode.toLowerCase().startsWith(lowerCaseQuery))
|
||||
.map(emoji => ({ ...emoji, custom: true }))
|
||||
|
||||
return [...emojis, ...customEmojis]
|
||||
},
|
||||
command: ({ editor, props, range }) => {
|
||||
const emoji: CustomEmoji | Emoji = props.emoji
|
||||
editor.commands.deleteRange(range)
|
||||
if (isCustomEmoji(emoji)) {
|
||||
editor.commands.insertCustomEmoji({
|
||||
title: emoji.shortcode,
|
||||
src: emoji.url,
|
||||
})
|
||||
}
|
||||
else {
|
||||
const skin = emoji.skins.find(skin => skin.native !== undefined)
|
||||
if (skin)
|
||||
editor.commands.insertEmoji(skin.native)
|
||||
}
|
||||
},
|
||||
render: createSuggestionRenderer(TiptapEmojiList),
|
||||
}
|
||||
|
||||
function createSuggestionRenderer(component: Component): SuggestionOptions['render'] {
|
||||
return () => {
|
||||
let renderer: VueRenderer
|
||||
let popup: Instance
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
renderer = new VueRenderer(component, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect)
|
||||
return
|
||||
|
||||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: renderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
|
||||
onBeforeUpdate: (props) => {
|
||||
if (props.editor.isFocused)
|
||||
renderer.updateProps({ ...props, isPending: true })
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
if (!props.editor.isFocused)
|
||||
return
|
||||
|
||||
renderer.updateProps({ ...props, isPending: false })
|
||||
|
||||
if (!props.clientRect)
|
||||
return
|
||||
|
||||
popup?.setProps({
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
return renderer?.ref?.onKeyDown(props.event)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
renderer?.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue