fix: use @tiptap/markdown API for Hashtag and ItemMention extensions

- Replace addStorage() with markdownTokenizer/parseMarkdown/renderMarkdown
- Remove tiptap-markdown from rollup commonjs config
- These changes fix serialization of hashtags and mentions to markdown
This commit is contained in:
Anton Tranelis 2026-01-15 09:12:35 +01:00
parent 237c84528b
commit 0ef35df335
3 changed files with 79 additions and 34 deletions

View File

@ -44,8 +44,6 @@ export default [
commonjs({ commonjs({
include: [ include: [
/node_modules\/attr-accept/, /node_modules\/attr-accept/,
/node_modules\/tiptap-markdown/,
/node_modules\/markdown-it-task-lists/,
/node_modules\/classnames/, /node_modules\/classnames/,
/node_modules\/react-qr-code/, /node_modules\/react-qr-code/,
/node_modules\/use-sync-external-store/, /node_modules\/use-sync-external-store/,

View File

@ -40,6 +40,45 @@ export const Hashtag = Node.create<HashtagOptions>({
} }
}, },
// Markdown tokenizer for @tiptap/markdown - recognizes #hashtag syntax
markdownTokenizer: {
name: 'hashtag',
level: 'inline',
// Fast hint for the lexer - where might a hashtag start?
start: (src: string) => {
// Look for # followed by word characters (but not inside links)
const match = /(?<!\[)#[a-zA-Z0-9À-ÖØ-öø-ʸ_-]/.exec(src)
return match ? match.index : -1
},
tokenize: (src: string) => {
// Match hashtag: #tagname (not preceded by [)
const match = /^#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)/.exec(src)
if (match) {
return {
type: 'hashtag',
raw: match[0],
label: match[1],
}
}
return undefined
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown(token: { label: string }) {
return {
type: 'hashtag',
attrs: {
label: token.label,
},
}
},
// Serialize Tiptap node to Markdown
renderMarkdown(node: { attrs: { label: string } }) {
return `#${node.attrs.label}`
},
addAttributes() { addAttributes() {
return { return {
id: { id: {
@ -89,20 +128,6 @@ export const Hashtag = Node.create<HashtagOptions>({
} }
}, },
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { label: string } }) {
// Write as plain hashtag
state.write(`#${node.attrs.label}`)
},
parse: {
// Parsing is handled by preprocessHashtags
},
},
}
},
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(HashtagComponent) return ReactNodeViewRenderer(HashtagComponent)
}, },

View File

@ -39,6 +39,46 @@ export const ItemMention = Node.create<ItemMentionOptions>({
} }
}, },
// Markdown tokenizer for @tiptap/markdown - recognizes [@Label](/item/id) syntax
markdownTokenizer: {
name: 'itemMention',
level: 'inline',
// Fast hint for the lexer - where might an item mention start?
start: (src: string) => src.indexOf('[@'),
tokenize: (src: string) => {
// Match [@Label](/item/id) or [@Label](/item/layer/id)
// UUID pattern: hex characters (case-insensitive) with dashes
// eslint-disable-next-line security/detect-unsafe-regex
const match = /^\[@([^\]]+?)\]\(\/item\/(?:[^/]+\/)?([a-fA-F0-9-]+)\)/.exec(src)
if (match) {
return {
type: 'itemMention',
raw: match[0],
label: match[1],
id: match[2],
}
}
return undefined
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown(token: { label: string; id: string }) {
return {
type: 'itemMention',
attrs: {
label: token.label,
id: token.id,
},
}
},
// Serialize Tiptap node to Markdown
renderMarkdown(node: { attrs: { label: string; id: string } }) {
const { label, id } = node.attrs
return `[@${label}](/item/${id})`
},
addAttributes() { addAttributes() {
return { return {
id: { id: {
@ -88,24 +128,6 @@ export const ItemMention = Node.create<ItemMentionOptions>({
} }
}, },
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: { id: string; label: string } },
) {
// Write as markdown link: [@Label](/item/id)
const { id, label } = node.attrs
state.write(`[@${label}](/item/${id})`)
},
parse: {
// Parsing is handled by preprocessItemMentions
},
},
}
},
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(ItemMentionComponent) return ReactNodeViewRenderer(ItemMentionComponent)
}, },