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 @@
-
-
+
-