Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web-pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@opencloud-eu/web-client": "workspace:^",
"@tiptap/core": "catalog:tiptap",
"@tiptap/extension-bubble-menu": "catalog:tiptap",
"@tiptap/extension-code": "catalog:tiptap",
"@tiptap/extension-code-block-lowlight": "catalog:tiptap",
"@tiptap/extension-collaboration": "catalog:tiptap",
"@tiptap/extension-document": "catalog:tiptap",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HtmlBlock,
HtmlInline,
imageFileHandlerExtension,
MarkdownCode,
MarkdownListItem,
MarkdownOrderedList,
MarkdownParagraph,
Expand Down Expand Up @@ -110,13 +111,15 @@ export const useStrategyMarkdown = (
return [
StarterKit.configure({
link: false,
code: false,
codeBlock: false,
document: false,
paragraph: false,
listItem: false,
orderedList: false,
undoRedo: options?.yjs ? false : undefined
}),
MarkdownCode,
MarkdownParagraph,
MarkdownListItem,
MarkdownOrderedList,
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/editor/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './frontmatterHighlight'
export * from './imageFileHandler'
export * from './link'
export * from './markdownClipboard'
export * from './markdownCode'
export * from './markdownHtml'
export * from './markdownLists'
export * from './markdownParagraph'
Expand Down
116 changes: 116 additions & 0 deletions packages/web-pkg/src/editor/extensions/markdownCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Code from '@tiptap/extension-code'
import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Transform } from '@tiptap/pm/transform'

/**
* Removes marks that cover only part of a code run. Markdown can only wrap a
* whole code span, e.g. "**`foo`**", anything else would be saved as syntax
* inside the backticks.
*/
function stripPartialMarks(tr: Transform, block: ProseMirrorNode, pos: number, code: MarkType) {
let run: { from: number; to: number; nodes: ProseMirrorNode[] } | null = null

const flush = () => {
if (run && run.nodes.length > 1) {
const { from, to, nodes } = run
const marks: Mark[] = []
nodes.forEach((node) =>
node.marks.forEach((mark) => {
if (!mark.isInSet(marks)) {
marks.push(mark)
}
})
)
marks.forEach((mark) => {
if (nodes.some((node) => !mark.isInSet(node.marks))) {
tr.removeMark(from, to, mark)
}
})
}
run = null
}

block.forEach((child, offset) => {
const from = pos + 1 + offset
if (!child.isText || !code.isInSet(child.marks)) {
flush()
return
}
run ??= { from, to: from, nodes: [] }
run.to = from + child.nodeSize
run.nodes.push(child)
})
flush()
}

function stripPartialMarksBetween(tr: Transform, from: number, to: number, code: MarkType) {
tr.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isTextblock) {
return true
}
stripPartialMarks(tr, node, pos, code)
return false
})
}

/**
* Inline code that can carry other marks. Markdown allows code spans inside
* bold, italic, strike and links, e.g. "**`foo`**", but tiptap's code mark
* excludes all marks, so such a document fails the schema check.
*
* The low priority sorts code last among the marks: mark rank decides the
* nesting when serializing, and code has to be the innermost one. It also runs
* `onBeforeCreate` after the Markdown extension has parsed the initial content.
*/
export const MarkdownCode = Code.extend({
priority: 50,
excludes: 'code',

onBeforeCreate() {
// the initial content is parsed without a transaction, the plugin never sees it
const { content } = this.editor.options
if (!content || typeof content !== 'object' || Array.isArray(content)) {
return
}

let doc: ProseMirrorNode
try {
doc = this.editor.schema.nodeFromJSON(content)
} catch {
// left for the editor to report
return
}

const tr = new Transform(doc)
stripPartialMarksBetween(tr, 0, doc.content.size, this.type)
if (tr.docChanged) {
this.editor.options.content = tr.doc.toJSON()
}
},

addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('markdownCodeMarks'),
appendTransaction: (transactions, oldState, newState) => {
// a read only peer's writes are dropped by the server
if (!this.editor.isEditable || !transactions.some((tr) => tr.docChanged)) {
return null
}

const start = oldState.doc.content.findDiffStart(newState.doc.content)
if (start === null) {
return null
}
const { b } = oldState.doc.content.findDiffEnd(newState.doc.content)
const end = Math.min(Math.max(b, start + 1), newState.doc.content.size)

const tr = newState.tr
stripPartialMarksBetween(tr, start, end, this.type)
return tr.docChanged ? tr : null
}
})
]
}
})
77 changes: 77 additions & 0 deletions packages/web-pkg/tests/unit/editor/extensions/markdownCode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
import type { Editor } from '@tiptap/vue-3'
import { createStrategy, createEditor, destroyEditors, load } from '../strategies/helpers'

vi.mock('vue3-gettext', () => ({
useGettext: () => ({ $gettext: (text: string) => text })
}))

function edit(markdown: string, change: (editor: Editor) => void): string {
const strategy = createStrategy()
const editor = createEditor(strategy, markdown)
change(editor)
return strategy.serialize(editor.state.doc)
}

describe('MarkdownCode', () => {
beforeEach(() => {
createTestingPinia()
})

afterEach(() => {
destroyEditors()
})

it('drops bold on part of a code span', () => {
const saved = edit('`foo bar`', (editor) => {
editor.chain().setTextSelection({ from: 5, to: 8 }).toggleBold().run()
})
expect(saved).toBe('`foo bar`')
})

it('drops a link on part of a code span', () => {
const saved = edit('`foo bar`', (editor) => {
editor
.chain()
.setTextSelection({ from: 5, to: 8 })
.setLink({ href: 'https://example.com' })
.run()
})
expect(saved).toBe('`foo bar`')
})

it('drops formatting inside a selection turned into code', () => {
const saved = edit('a **b** c', (editor) => {
editor.chain().setTextSelection({ from: 1, to: 6 }).toggleCode().run()
})
expect(saved).toBe('`a b c`')
})

it('keeps bold on a whole code span', () => {
const saved = edit('`foo bar`', (editor) => {
editor.chain().setTextSelection({ from: 1, to: 8 }).toggleBold().run()
})
expect(saved).toBe('**`foo bar`**')
})

it('keeps bold around a code span and surrounding text', () => {
const saved = edit('a `b` c', (editor) => {
editor.chain().setTextSelection({ from: 1, to: 6 }).toggleBold().run()
})
expect(saved).toBe('**a `b` c**')
})

it.each([
['`a`**`b`**', '`ab`'],
['`a`[`b`](https://example.com)', '`ab`'],
['- `a`*`b`*', '- `ab`'],
['| head |\n| ---- |\n| `a`~~`b`~~ |', '| head |\n| ---- |\n| `ab` |']
])('drops marks on part of adjacent code spans when loading %j', (markdown, expected) => {
expect(load(markdown).serialized.trim()).toBe(expected)
})

it('ranks code innermost among the marks', () => {
const { editor } = load('')
expect(Object.keys(editor.schema.marks).at(-1)).toBe('code')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Set `allowDirectoryQueries = true` on **`Sharing`**.

**some `code` here**

*`italic`* and ~~`strike`~~

See [`config.json`](https://example.com) and [the `docs` page](https://example.com).
11 changes: 11 additions & 0 deletions packages/web-pkg/tests/unit/editor/yjsAdapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,17 @@ describe('makeTiptapYjsAdapter', () => {
expect(adapter.hasContent(ydoc)).toBe(false)
ydoc.destroy()
})

it('hydrates inline code inside other marks', () => {
const adapter = makeTiptapYjsAdapter(useStrategyMarkdown(createState()))
const ydoc = new Y.Doc()
const content = '**`Sharing`** and [`link`](https://example.com)'

adapter.hydrate(ydoc, content)

expect(adapter.serialize(ydoc)).toBe(content)
ydoc.destroy()
})
})

describe('hasContent', () => {
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ catalogs:
tiptap:
'@tiptap/core': ^3.30.3
'@tiptap/extension-bubble-menu': ^3.30.3
'@tiptap/extension-code': ^3.30.3
'@tiptap/extension-code-block-lowlight': ^3.30.3
'@tiptap/extension-collaboration': ^3.30.3
'@tiptap/extension-document': ^3.30.3
Expand Down
Loading