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
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)))
)
Comment thread
waleedlatif1 marked this conversation as resolved.
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
@@ -1,5 +1,6 @@
import type { Extensions } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { BlockMover } from './block-mover'
import { CodeBlockWithLanguage } from './code-block'
import { CodeBlockHighlight } from './code-highlight'
import { LinkEmbed } from './embed/link-embed'
Expand Down Expand Up @@ -44,6 +45,7 @@ export function createMarkdownEditorExtensions({
SlashCommand,
Mention,
RichMarkdownKeymap,
BlockMover,
MarkdownPaste,
Placeholder.configure({ placeholder }),
...(embeds ? [LinkEmbed] : []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { Markdown } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'
import { MarkdownCodeBlock } from './code-block'
import { Highlight } from './highlight'
import { MarkdownImage } from './image'
import { MarkdownLinkInputRule } from './link-input-rule'
import { MarkdownMention } from './mention/mention-node'
Expand Down Expand Up @@ -130,6 +131,7 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
}),
BlockSafeParagraph,
InlineCode,
Highlight,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
nodeViews.mention ?? MarkdownMention,
Expand Down
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 })]
Comment thread
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 ?? [])}==`
Comment thread
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
},
}),
]
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,73 @@ describe('verbatim block boundary (isolating)', () => {
}
)
})

describe('block reordering (Mod-Shift-Arrow)', () => {
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn()
})

function caretInto(editor: Editor, word: string): void {
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text?.includes(word)) editor.commands.setTextSelection(pos + 1)
})
}

it('moves the current top-level block up, carrying the caret', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para\n\n- item', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'Two')
editor.commands.moveBlockUp()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})

it('moves the current top-level block down', () => {
const editor = editorWith('')
editor.commands.setContent('# One\n\nTwo para', { contentType: 'markdown' })
editor.commands.focus()
caretInto(editor, 'One')
editor.commands.moveBlockDown()
expect(editor.getMarkdown().trim().startsWith('Two para')).toBe(true)
editor.destroy()
})

it.each([
['up', '# One\n\nabcdef'],
['down', 'abcdef\n\n# Two'],
])('keeps the caret at its original offset after moving %s (no off-by-one)', (direction, md) => {
const editor = editorWith('')
editor.commands.setContent(md, { contentType: 'markdown' })
editor.commands.focus()
let textPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text === 'abcdef') textPos = pos
})
editor.commands.setTextSelection(textPos + 3)
if (direction === 'up') editor.commands.moveBlockUp()
else editor.commands.moveBlockDown()
const at = editor.state.selection.from
expect(editor.state.doc.textBetween(at - 1, at)).toBe('c')
expect(editor.state.doc.textBetween(at, at + 1)).toBe('d')
editor.destroy()
})

it('is a no-op at the top edge and keeps a moved list intact', () => {
const top = editorWith('')
top.commands.setContent('# One\n\nTwo', { contentType: 'markdown' })
top.commands.focus()
caretInto(top, 'One')
top.commands.moveBlockUp()
expect(top.getMarkdown().trim().startsWith('# One')).toBe(true)
top.destroy()

const list = editorWith('')
list.commands.setContent('- a\n- b\n\npara', { contentType: 'markdown' })
list.commands.focus()
caretInto(list, 'para')
list.commands.moveBlockUp()
expect(list.getMarkdown().trim()).toBe('para\n\n- a\n- b')
list.destroy()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
* same scoped behavior as a code editor.
* - **ArrowUp/ArrowDown** select an adjacent divider or image, whether arrowing off a textblock edge
* ({@link selectAdjacentLeaf}) or stepping from one already-selected leaf to the next
* ({@link selectAdjacentSelectedLeaf}).
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
* in `./block-mover.ts`.)
*
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
Expand Down
Loading
Loading