diff --git a/package.json b/package.json index f9f5fb2..f1d2881 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,14 @@ "type": "module", "scripts": { "dev": "vite --host", + "dev-test": "vite --host --mode=test", "build": "vue-tsc && vite build", "preview": "vite preview" }, "dependencies": { + "@tiptap/core": "^2.10.4", + "@tiptap/pm": "^2.10.4", + "@tiptap/vue-3": "^2.10.4", "ant-design-vue": "^4.2.6", "autoprefixer": "^10.4.20", "axios": "^1.7.9", diff --git a/src/components/editor/tiptap.vue b/src/components/editor/tiptap.vue new file mode 100644 index 0000000..20212ef --- /dev/null +++ b/src/components/editor/tiptap.vue @@ -0,0 +1,49 @@ + + + + \ No newline at end of file diff --git a/src/components/editor/tiptap/ext-document.ts b/src/components/editor/tiptap/ext-document.ts new file mode 100644 index 0000000..2d4c574 --- /dev/null +++ b/src/components/editor/tiptap/ext-document.ts @@ -0,0 +1,7 @@ +import { Node } from '@tiptap/core' + +export const Document = Node.create({ + name: 'doc', + topNode: true, + content: 'block+', +}) \ No newline at end of file diff --git a/src/components/editor/tiptap/ext-paragraph.ts b/src/components/editor/tiptap/ext-paragraph.ts new file mode 100644 index 0000000..765c9b7 --- /dev/null +++ b/src/components/editor/tiptap/ext-paragraph.ts @@ -0,0 +1,66 @@ +import { mergeAttributes, Node } from '@tiptap/core' + +export interface ParagraphOptions { + /** + * The HTML attributes for a paragraph node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record, +} + +declare module '@tiptap/core' { + interface Commands { + paragraph: { + /** + * Toggle a paragraph + * @example editor.commands.toggleParagraph() + */ + setParagraph: () => ReturnType, + } + } +} + +/** + * This extension allows you to create paragraphs. + * @see https://www.tiptap.dev/api/nodes/paragraph + */ +export const Paragraph = Node.create({ + name: 'paragraph', + + priority: 1000, + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + group: 'block', + + content: 'inline*', + + parseHTML() { + return [ + { tag: 'p' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addCommands() { + return { + setParagraph: () => ({ commands }) => { + return commands.setNode(this.name) + }, + } + }, + + addKeyboardShortcuts() { + return { + 'Mod-Alt-0': () => this.editor.commands.setParagraph(), + } + }, +}) diff --git a/src/components/editor/tiptap/ext-placeholder.ts b/src/components/editor/tiptap/ext-placeholder.ts new file mode 100644 index 0000000..fc31944 --- /dev/null +++ b/src/components/editor/tiptap/ext-placeholder.ts @@ -0,0 +1,139 @@ +import { Editor, Extension, isNodeEmpty } from '@tiptap/core' +import { Node as ProsemirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export interface PlaceholderOptions { + /** + * **The class name for the empty editor** + * @default 'is-editor-empty' + */ + emptyEditorClass: string + + /** + * **The class name for empty nodes** + * @default 'is-empty' + */ + emptyNodeClass: string + + /** + * **The placeholder content** + * + * You can use a function to return a dynamic placeholder or a string. + * @default 'Write something …' + */ + placeholder: + | ((PlaceholderProps: { + editor: Editor + node: ProsemirrorNode + pos: number + hasAnchor: boolean + }) => string) + | string + + /** + * See https://github.com/ueberdosis/tiptap/pull/5278 for more information. + * @deprecated This option is no longer respected and this type will be removed in the next major version. + */ + considerAnyAsEmpty?: boolean + + /** + * **Checks if the placeholder should be only shown when the editor is editable.** + * + * If true, the placeholder will only be shown when the editor is editable. + * If false, the placeholder will always be shown. + * @default true + */ + showOnlyWhenEditable: boolean + + /** + * **Checks if the placeholder should be only shown when the current node is empty.** + * + * If true, the placeholder will only be shown when the current node is empty. + * If false, the placeholder will be shown when any node is empty. + * @default true + */ + showOnlyCurrent: boolean + + /** + * **Controls if the placeholder should be shown for all descendents.** + * + * If true, the placeholder will be shown for all descendents. + * If false, the placeholder will only be shown for the current node. + * @default false + */ + includeChildren: boolean +} + +/** + * This extension allows you to add a placeholder to your editor. + * A placeholder is a text that appears when the editor or a node is empty. + * @see https://www.tiptap.dev/api/extensions/placeholder + */ +export const Placeholder = Extension.create({ + name: 'placeholder', + + addOptions() { + return { + emptyEditorClass: 'is-editor-empty', + emptyNodeClass: 'is-empty', + placeholder: 'Write something …', + showOnlyWhenEditable: true, + showOnlyCurrent: true, + includeChildren: false, + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('placeholder'), + props: { + decorations: ({ doc, selection }) => { + const active = this.editor.isEditable || !this.options.showOnlyWhenEditable + const { anchor } = selection + const decorations: Decoration[] = [] + + if (!active) { + return null + } + + const isEmptyDoc = this.editor.isEmpty + + doc.descendants((node, pos) => { + const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize + const isEmpty = !node.isLeaf && isNodeEmpty(node) + + if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) { + const classes = [this.options.emptyNodeClass] + + if (isEmptyDoc) { + classes.push(this.options.emptyEditorClass) + } + + const decoration = Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(' '), + 'data-placeholder': + typeof this.options.placeholder === 'function' + ? this.options.placeholder({ + editor: this.editor, + node, + pos, + hasAnchor, + }) + : this.options.placeholder, + }) + + decorations.push(decoration) + } + + return this.options.includeChildren + }) + + return DecorationSet.create(doc, decorations) + }, + }, + }), + ] + }, +}) \ No newline at end of file diff --git a/src/components/editor/tiptap/ext-text.ts b/src/components/editor/tiptap/ext-text.ts new file mode 100644 index 0000000..7aa2ab6 --- /dev/null +++ b/src/components/editor/tiptap/ext-text.ts @@ -0,0 +1,7 @@ +import { Node } from '@tiptap/core' + + +export const Text = Node.create({ + name: 'text', + group: 'inline' +}) \ No newline at end of file diff --git a/src/components/editor/tiptap/ext-vars.ts b/src/components/editor/tiptap/ext-vars.ts new file mode 100644 index 0000000..62aec02 --- /dev/null +++ b/src/components/editor/tiptap/ext-vars.ts @@ -0,0 +1,267 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' +import { PluginKey } from '@tiptap/pm/state' +import Suggestion, { SuggestionOptions } from '@tiptap/suggestion' + +// See `addAttributes` below +export interface MentionNodeAttrs { + /** + * The identifier for the selected item that was mentioned, stored as a `data-id` + * attribute. + */ + id: string | null; + /** + * The label to be rendered by the editor as the displayed text for this mentioned + * item, if provided. Stored as a `data-label` attribute. See `renderLabel`. + */ + label?: string | null; +} + +export type MentionOptions = MentionNodeAttrs> = { + /** + * The HTML attributes for a mention node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + + /** + * A function to render the label of a mention. + * @deprecated use renderText and renderHTML instead + * @param props The render props + * @returns The label + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderLabel?: (props: { options: MentionOptions; node: ProseMirrorNode }) => string + + /** + * A function to render the text of a mention. + * @param props The render props + * @returns The text + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderText: (props: { options: MentionOptions; node: ProseMirrorNode }) => string + + /** + * A function to render the HTML of a mention. + * @param props The render props + * @returns The HTML as a ProseMirror DOM Output Spec + * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`] + */ + renderHTML: (props: { options: MentionOptions; node: ProseMirrorNode }) => DOMOutputSpec + + /** + * Whether to delete the trigger character with backspace. + * @default false + */ + deleteTriggerWithBackspace: boolean + + /** + * The suggestion options. + * @default {} + * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } } + */ + suggestion: Omit, 'editor'> +} + +/** + * The plugin key for the mention plugin. + * @default 'mention' + */ +export const MentionPluginKey = new PluginKey('mention') + +/** + * This extension allows you to insert mentions into the editor. + * @see https://www.tiptap.dev/api/extensions/mention + */ +export const Mention = Node.create({ + name: 'mention', + + priority: 101, + + addOptions() { + return { + HTMLAttributes: {}, + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + deleteTriggerWithBackspace: false, + renderHTML({ options, node }) { + return [ + 'span', + mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + ] + }, + suggestion: { + char: '@', + pluginKey: MentionPluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + .run() + + // get reference to `window` object from editor element, to support cross-frame JS usage + editor.view.dom.ownerDocument.defaultView?.getSelection()?.collapseToEnd() + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[this.name] + const allow = !!$from.parent.type.contentMatch.matchType(type) + + return allow + }, + }, + } + }, + + group: 'inline', + + inline: true, + + selectable: false, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: element => element.getAttribute('data-id'), + renderHTML: attributes => { + if (!attributes.id) { + return {} + } + + return { + 'data-id': attributes.id, + } + }, + }, + + label: { + default: null, + parseHTML: element => element.getAttribute('data-label'), + renderHTML: attributes => { + if (!attributes.label) { + return {} + } + + return { + 'data-label': attributes.label, + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + if (this.options.renderLabel !== undefined) { + console.warn('renderLabel is deprecated use renderText and renderHTML instead') + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + } + const mergedOptions = { ...this.options } + + mergedOptions.HTMLAttributes = mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes) + const html = this.options.renderHTML({ + options: mergedOptions, + node, + }) + + if (typeof html === 'string') { + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + html, + ] + } + return html + }, + + renderText({ node }) { + if (this.options.renderLabel !== undefined) { + console.warn('renderLabel is deprecated use renderText and renderHTML instead') + return this.options.renderLabel({ + options: this.options, + node, + }) + } + return this.options.renderText({ + options: this.options, + node, + }) + }, + + addKeyboardShortcuts() { + return { + Backspace: () => this.editor.commands.command(({ tr, state }) => { + let isMention = false + const { selection } = state + const { empty, anchor } = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true + tr.insertText( + this.options.deleteTriggerWithBackspace ? '' : this.options.suggestion.char || '', + pos, + pos + node.nodeSize, + ) + + return false + } + }) + + return isMention + }), + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) \ No newline at end of file diff --git a/src/components/editor/tiptap/index.ts b/src/components/editor/tiptap/index.ts new file mode 100644 index 0000000..39fbc92 --- /dev/null +++ b/src/components/editor/tiptap/index.ts @@ -0,0 +1,12 @@ +import { Text } from './ext-text.ts' +import { Paragraph } from './ext-paragraph.ts' +import { Document } from './ext-document.ts' +export {Placeholder } from './ext-placeholder.ts' + + +// export +export const extensions = [ + Text, + Paragraph, + Document, +] \ No newline at end of file diff --git a/src/pages/result/index.vue b/src/pages/result/index.vue index a1ca124..d0c0836 100644 --- a/src/pages/result/index.vue +++ b/src/pages/result/index.vue @@ -1,104 +1,63 @@