diff --git a/css/prosemirror.scss b/css/prosemirror.scss index bb2d778e5a6..4c04a44533f 100644 --- a/css/prosemirror.scss +++ b/css/prosemirror.scss @@ -256,6 +256,33 @@ div.ProseMirror { } + /* Anchor links */ + h1, h2, h3, h4, h5, h6 { + .anchor-link { + opacity: 0; + padding: 0; + left: -18px; + font-size: max(1em, 16px); + position: absolute; + text-decoration: none; + transition-duration: .15s; + transition-property: opacity; + transition-timing-function: cubic-bezier(.4,0,.2,1); + } + + &:hover .anchor-link { + opacity: 0.25; + } + } + // Shrink clickable area of anchor permalinks while editing + &.ProseMirror-focused[contenteditable="true"] { + h1,h2,h3,h4,h5,h6 { + .anchor-link { + width: fit-content; + } + } + } + } .ProseMirror-focused .ProseMirror-gapcursor { diff --git a/package-lock.json b/package-lock.json index ca50a9c329b..47f08f192b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,8 @@ "escape-html": "^1.0.3", "highlight.js": "^10.7.2", "lowlight": "^1.20.0", - "markdown-it": "^13.0.0", + "markdown-it": "^13.0.1", + "markdown-it-anchor": "^8.6.4", "markdown-it-container": "^3.0.0", "markdown-it-task-lists": "^2.1.1", "prosemirror-collab": "^1.2.2", @@ -3908,11 +3909,33 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "peer": true + }, "node_modules/@types/lowlight": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/lowlight/-/lowlight-0.0.3.tgz", "integrity": "sha512-R83q/yPX2nIlo9D3WtSjyUDd57t8s+GVLaL8YIv3k7zMMWpYpOXqjJgrWp80qXUJB/a1t76nTyBpxrv0JNYaEg==" }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "peer": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "peer": true + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -13322,6 +13345,15 @@ "markdown-it": "bin/markdown-it.js" } }, + "node_modules/markdown-it-anchor": { + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.4.tgz", + "integrity": "sha512-Ul4YVYZNxMJYALpKtu+ZRdrryYt/GlQ5CK+4l1bp/gWXOG2QWElt6AqF3Mih/wfUKdZbNAZVXGR73/n6U/8img==", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, "node_modules/markdown-it-container": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", @@ -21804,11 +21836,33 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==", + "peer": true + }, "@types/lowlight": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/lowlight/-/lowlight-0.0.3.tgz", "integrity": "sha512-R83q/yPX2nIlo9D3WtSjyUDd57t8s+GVLaL8YIv3k7zMMWpYpOXqjJgrWp80qXUJB/a1t76nTyBpxrv0JNYaEg==" }, + "@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "peer": true, + "requires": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==", + "peer": true + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -29127,6 +29181,12 @@ } } }, + "markdown-it-anchor": { + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.4.tgz", + "integrity": "sha512-Ul4YVYZNxMJYALpKtu+ZRdrryYt/GlQ5CK+4l1bp/gWXOG2QWElt6AqF3Mih/wfUKdZbNAZVXGR73/n6U/8img==", + "requires": {} + }, "markdown-it-container": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-3.0.0.tgz", diff --git a/package.json b/package.json index 1ea60907b3a..be5ccafc825 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "escape-html": "^1.0.3", "highlight.js": "^10.7.2", "lowlight": "^1.20.0", - "markdown-it": "^13.0.0", + "markdown-it": "^13.0.1", + "markdown-it-anchor": "^8.6.4", "markdown-it-container": "^3.0.0", "markdown-it-task-lists": "^2.1.1", "prosemirror-collab": "^1.2.2", diff --git a/src/EditorFactory.js b/src/EditorFactory.js index cbdd56a4889..104e6d86236 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -40,6 +40,10 @@ import TableHeadRow from './nodes/TableHeadRow.js' import TableRow from './nodes/TableRow.js' /* eslint-enable import/no-named-as-default */ +// fixing "The type 'EditorState' is undefined" +import { EditorState } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' + import { Editor } from '@tiptap/core' import { Strong, Italic, Strike, Link, Underline } from './marks/index.js' import { @@ -76,7 +80,32 @@ const loadSyntaxHighlight = async (language) => { } } -const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditing, currentDirectory }) => { +const editorDecorations = (/** @param {EditorState} state */ state) => { + const decorations = [] + state.doc.descendants((node, pos) => { + if (node.type.name === Heading.name && node.attrs?.id) { + decorations.push( + Decoration.widget(pos + 1, () => { + const link = document.createElement('a') + link.ariaHidden = true + link.href = `#${node.attrs.id}` + link.classList.add('anchor-link') + link.title = t('text', 'Permalink') + link.appendChild(document.createTextNode('#')) + return link + }, { + side: -1, + key: `${pos}#${node.attrs.id}`, // Prevent decoration rendering loops + }) + ) + return false + } + return true + }) + return decorations.length > 0 ? DecorationSet.create(state.doc, decorations) : null +} + +const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditing, currentDirectory, isRichWorkspace }) => { let richEditingExtensions = [] if (enableRichEditing) { richEditingExtensions = [ @@ -169,7 +198,7 @@ const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditi ] } extensions = extensions || [] - return new Editor({ + const editor = new Editor({ content, onCreate, onUpdate, @@ -179,6 +208,12 @@ const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditi ...richEditingExtensions, ].concat(extensions), }) + + if (!isRichWorkspace) { + editor.view.props.decorations = editorDecorations + } + + return editor } const SerializeException = function(message) { diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index cf1c2ffeb38..ad7165f551e 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -408,6 +408,7 @@ export default { loadSyntaxHighlight(language).then(() => { this.$editor = createEditor({ content, + isRichWorkspace: this.isRichWorkspace, onCreate: ({ editor }) => { this.$syncService.state = editor.state this.$syncService.startSync() diff --git a/src/markdownit/index.js b/src/markdownit/index.js index ca166dbad18..50c09772830 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -1,9 +1,12 @@ import MarkdownIt from 'markdown-it' +import anchor from 'markdown-it-anchor' import taskLists from 'markdown-it-task-lists' import underline from './underline.js' import splitMixedLists from './splitMixedLists.js' import callouts from './callouts.js' +import { slugify } from '../nodes/Heading.js' + const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .enable('strikethrough') .enable('table') @@ -11,5 +14,9 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(splitMixedLists) .use(underline) .use(callouts) + .use(anchor, { + level: 1, + slugify, + }) export default markdownit diff --git a/src/nodes/Heading.js b/src/nodes/Heading.js index 0ec91c36fce..9d1ac8cbe62 100644 --- a/src/nodes/Heading.js +++ b/src/nodes/Heading.js @@ -1,6 +1,21 @@ import TipTapHeading from '@tiptap/extension-heading' +import { Plugin } from 'prosemirror-state' -const Heading = TipTapHeading.extend({ +// Same style as used by gitlab and github, both support unicode letters (incl. mark) +const slugify = (str) => String(str).toLowerCase().replace(/[^\p{Letter}\p{Mark}\w\s-]/gu, '').trim().replace(/\s+/g, '-') + +const uniqueSlug = (slug, slugs) => { + let uniq = slug + let idx = 1 + while (Object.prototype.hasOwnProperty.call(slugs, uniq)) { + uniq = `${slug}-${idx}` + idx += 1 + } + slugs[uniq] = true + return uniq +} + +const HeadingWithAnchor = TipTapHeading.extend({ addKeyboardShortcuts() { return this.options.levels.reduce((items, level) => ({ @@ -9,6 +24,56 @@ const Heading = TipTapHeading.extend({ }), {}) }, + addStorage() { + return { + slugs: {}, // used for the parseHTML function + } + }, + + addAttributes() { + return { + ...this.parent?.(), + tabindex: { + default: -1, + }, + id: { + default: null, + parseHTML: (element) => { + return uniqueSlug(slugify(element.innerText), this.storage.slugs) + }, + }, + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + appendTransaction(transactions, oldState, newState) { + const slugs = {} + const tr = newState.tr + let modified = false + + newState.doc.descendants( + (node, pos) => { + if (node.type.name === TipTapHeading.name) { + if (node.textContent.length > 0) { + const slug = uniqueSlug(slugify(node.textContent), slugs) + if (node.attrs?.id !== slug) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: slug }) + modified = true + } + } + return false + } + return true + } + ) + return modified ? tr : null + }, + }), + ] + }, }) -export default Heading +export { slugify, HeadingWithAnchor } +export default HeadingWithAnchor diff --git a/src/plugins/link.js b/src/plugins/link.js index 5f352851e79..41008c3e1a3 100644 --- a/src/plugins/link.js +++ b/src/plugins/link.js @@ -19,6 +19,12 @@ const clickHandler = ({ editor }) => { event.stopPropagation() if (event.button === 0 && !event.ctrlKey && htmlHref.startsWith(window.location.origin)) { + if (window.location.href.split('#')[0] === htmlHref.split('#')[0]) { + // Inter-page link, so move to location + window.open(htmlHref, '_self') + return + } + const query = OC.parseQueryString(htmlHref) const fragment = OC.parseQueryString(htmlHref.split('#').pop()) if (query.dir && fragment.relPath) {