Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(rich-markdown-editor): round-trip gate, VSCode/style paste, highlight, block reorder#5539
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
457486602fa9308f741a8f2b07b1File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { Extension } from '@tiptap/core' | ||
| import type { EditorState, Transaction } from '@tiptap/pm/state' | ||
| import { TextSelection } from '@tiptap/pm/state' | ||
| /** The position range of the depth-1 block containing the cursor, or null at the document root. */ | ||
| function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null { | ||
| const { $from } = state.selection | ||
| if ($from.depth === 0) return null | ||
| return { from: $from.before(1), to: $from.after(1) } | ||
| } | ||
| /** | ||
| * Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved | ||
| * block. Adjacent top-level blocks share a boundary position (no separator token between them), so the | ||
| * move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false) | ||
| * at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved | ||
| * block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also | ||
| * measured from `before(1)`) re-anchors the caret at the same spot within the block. | ||
| */ | ||
| function moveBlock( | ||
| state: EditorState, | ||
| dispatch: ((tr: Transaction) => void) | undefined, | ||
| direction: 'up' | 'down' | ||
| ): boolean { | ||
| const block = currentTopLevelBlock(state) | ||
| if (!block) return false | ||
| const { from, to } = block | ||
| const up = direction === 'up' | ||
| if (up ? from === 0 : to >= state.doc.content.size) return false | ||
| const $neighbour = state.doc.resolve(up ? from - 1 : to + 1) | ||
| if ($neighbour.depth === 0) return false | ||
| if (!dispatch) return true | ||
| const spanFrom = up ? $neighbour.before(1) : from | ||
| const spanTo = up ? to : $neighbour.after(1) | ||
| const moving = state.doc.slice(from, to).content | ||
| const neighbour = up | ||
| ? state.doc.slice(spanFrom, from).content | ||
| : state.doc.slice(to, spanTo).content | ||
| const tr = state.tr.replaceWith( | ||
| spanFrom, | ||
| spanTo, | ||
| up ? moving.append(neighbour) : neighbour.append(moving) | ||
| ) | ||
| const newBefore = up ? spanFrom : spanFrom + neighbour.size | ||
| const offset = state.selection.from - from | ||
| tr.setSelection( | ||
| TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size))) | ||
| ) | ||
| dispatch(tr.scrollIntoView()) | ||
| return true | ||
| } | ||
| declare module '@tiptap/core' { | ||
| interface Commands<ReturnType> { | ||
| blockMover: { | ||
| /** Move the current top-level block up one position, carrying the caret. */ | ||
| moveBlockUp: () => ReturnType | ||
| /** Move the current top-level block down one position, carrying the caret. */ | ||
| moveBlockDown: () => ReturnType | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard | ||
| * keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the | ||
| * caret rides along with the block. A no-op (returns false, falling through) at the document edges. | ||
| */ | ||
| export const BlockMover = Extension.create({ | ||
| name: 'blockMover', | ||
| addCommands() { | ||
| return { | ||
| moveBlockUp: | ||
| () => | ||
| ({ state, dispatch }) => | ||
| moveBlock(state, dispatch, 'up'), | ||
| moveBlockDown: | ||
| () => | ||
| ({ state, dispatch }) => | ||
| moveBlock(state, dispatch, 'down'), | ||
| } | ||
| }, | ||
| addKeyboardShortcuts() { | ||
| return { | ||
| 'Mod-Shift-ArrowUp': ({ editor }) => editor.commands.moveBlockUp(), | ||
| 'Mod-Shift-ArrowDown': ({ editor }) => editor.commands.moveBlockDown(), | ||
| } | ||
| }, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import type { | ||
| JSONContent, | ||
| MarkdownParseHelpers, | ||
| MarkdownRendererHelpers, | ||
| MarkdownToken, | ||
| } from '@tiptap/core' | ||
| import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core' | ||
| import type { Transaction } from '@tiptap/pm/state' | ||
| import { Plugin } from '@tiptap/pm/state' | ||
| /** | ||
| * `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=` | ||
| * (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while | ||
| * the closing `==` still terminates the run. | ||
| */ | ||
| const HIGHLIGHT_BODY = String.raw`(?:[^=]|=(?!=))+?` | ||
| const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`) | ||
| /** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */ | ||
| const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`) | ||
| const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g') | ||
| /** | ||
| * Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no | ||
| * highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline | ||
| * markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content | ||
| * in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`. | ||
| * | ||
| * The tokenizer's `start` returns the index of the next `==` (a plain string search, not the | ||
| * `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline | ||
| * text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`. | ||
| * | ||
| * A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the | ||
| * `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The | ||
| * tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any | ||
| * text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an | ||
| * unrepresentable highlight and serialization stays lossless. | ||
| */ | ||
| export const Highlight = Mark.create({ | ||
| name: 'highlight', | ||
| parseHTML() { | ||
| return [{ tag: 'mark' }] | ||
| }, | ||
| renderHTML({ HTMLAttributes }) { | ||
| return ['mark', mergeAttributes(HTMLAttributes), 0] | ||
| }, | ||
| addInputRules() { | ||
| return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })] | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }, | ||
| addPasteRules() { | ||
| return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })] | ||
| }, | ||
| addKeyboardShortcuts() { | ||
| return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) } | ||
| }, | ||
| markdownTokenName: 'highlight', | ||
| markdownTokenizer: { | ||
| name: 'highlight', | ||
| level: 'inline' as const, | ||
| start: (src: string) => src.indexOf('=='), | ||
| tokenize(src: string): MarkdownToken | undefined { | ||
| const match = HIGHLIGHT_TOKEN.exec(src) | ||
| if (!match) return undefined | ||
| return { type: 'highlight', raw: match[0], text: match[1] } | ||
| }, | ||
| }, | ||
| parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) { | ||
| const inner = token.text ?? '' | ||
| const tokens = helpers.tokenizeInline?.(inner) | ||
| const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }] | ||
| return { mark: 'highlight', content } | ||
| }, | ||
| renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) { | ||
| return `==${h.renderChildren(node.content ?? [])}==` | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }, | ||
| addProseMirrorPlugins() { | ||
| const markType = this.type | ||
| return [ | ||
| new Plugin({ | ||
| appendTransaction(transactions, _oldState, newState) { | ||
| if (!transactions.some((transaction) => transaction.docChanged)) return null | ||
| let tr: Transaction | null = null | ||
| newState.doc.descendants((node, pos) => { | ||
| if ( | ||
| node.isText && | ||
| node.text?.includes('==') && | ||
| node.marks.some((mark) => mark.type === markType) | ||
| ) { | ||
| tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType) | ||
| } | ||
| }) | ||
| return tr | ||
| }, | ||
| }), | ||
| ] | ||
| }, | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.