-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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
Merged
+545
−7
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
4574866
fix(rich-markdown-editor): tighten read-only gate for uppercase entit…
waleedlatif1 02fa930
feat(rich-markdown-editor): VSCode code paste and strip <style>/<scri…
waleedlatif1 8f741a8
feat(rich-markdown-editor): add highlight (==mark==) support
waleedlatif1 f2b07b1
feat(rich-markdown-editor): keyboard block reordering (Mod-Shift-Arrow)
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
94 changes: 94 additions & 0 deletions
94
.../workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| } | ||
| }, | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
...pp/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
|
||
| }, | ||
|
|
||
| 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.
|
||
| }, | ||
|
|
||
| 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 | ||
| }, | ||
| }), | ||
| ] | ||
| }, | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.