diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts new file mode 100644 index 00000000000..9c930173755 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts @@ -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 { + 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(), + } + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 01a2e2c82dd..601d702c07c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -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' @@ -44,6 +45,7 @@ export function createMarkdownEditorExtensions({ SlashCommand, Mention, RichMarkdownKeymap, + BlockMover, MarkdownPaste, Placeholder.configure({ placeholder }), ...(embeds ? [LinkEmbed] : []), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index bf91dfd2cd9..52e4731f27f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -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' @@ -130,6 +131,7 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} }), BlockSafeParagraph, InlineCode, + Highlight, codeBlock, (nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }), nodeViews.mention ?? MarkdownMention, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight.ts new file mode 100644 index 00000000000..948b75127f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight.ts @@ -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})(?`), 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 })] + }, + + 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 ?? [])}==` + }, + + 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 + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 1f12ca3b7ee..862414f9fd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -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() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 6fb6ad4ac66..3034fd66fe6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index b36857c037e..9abccb28df6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -21,10 +21,11 @@ function mount(editable = true): Editor { } /** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */ -function paste(ed: Editor, text: string, html = ''): boolean { +function paste(ed: Editor, text: string, html = '', extra: Record = {}): boolean { const event = { clipboardData: { - getData: (type: string) => (type === 'text/plain' ? text : type === 'text/html' ? html : ''), + getData: (type: string) => + type === 'text/plain' ? text : type === 'text/html' ? html : (extra[type] ?? ''), }, } as unknown as ClipboardEvent for (const plugin of ed.view.state.plugins) { @@ -35,6 +36,16 @@ function paste(ed: Editor, text: string, html = ''): boolean { return false } +/** Run the plugin `transformPastedHTML` chain the way ProseMirror would. */ +function transformHtml(ed: Editor, html: string): string { + let out = html + for (const plugin of ed.view.state.plugins) { + const fn = plugin.props?.transformPastedHTML + if (fn) out = fn.call(plugin.props, out, ed.view) + } + return out +} + describe('markdown paste', () => { it('renders a pasted inline link as a link mark', () => { editor = mount() @@ -124,6 +135,8 @@ describe('markdown paste', () => { ['underscore italic', 'an _italic_ word', 'italic'], ['underscore bold', 'a __bold__ word', 'bold'], ['strikethrough', 'a ~~struck~~ word', 'strike'], + ['highlight', 'a ==marked== word', 'highlight'], + ['highlight with interior equals', 'x ==a=b== y', 'highlight'], ['inline code', 'some `code` here', 'code'], ['bullet list', '- one\n- two', 'bulletList'], ['ordered list', '1. one\n2. two', 'orderedList'], @@ -178,4 +191,53 @@ describe('markdown paste', () => { .filter((type) => type !== 'paragraph') expect(structural).toEqual(['heading', 'bulletList', 'blockquote']) }) + + it('pastes VSCode code (vscode-editor-data) as a fenced code block with its language', () => { + editor = mount() + const code = 'const x: number = 1\nreturn x' + const handled = paste(editor, code, '
const
', { + 'vscode-editor-data': JSON.stringify({ mode: 'typescript' }), + }) + expect(handled).toBe(true) + const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock') + expect(block).toBeDefined() + expect(block?.attrs?.language).toBe('typescript') + expect(block?.content?.[0]?.text).toBe(code) + }) + + it.each([ + ['html', 'markup'], + ['shellscript', 'bash'], + ])('maps VSCode language id %s to our code-block value %s', (mode, expected) => { + editor = mount() + paste(editor, 'code', '', { 'vscode-editor-data': JSON.stringify({ mode }) }) + const block = (editor.getJSON().content ?? []).find((n) => n.type === 'codeBlock') + expect(block?.attrs?.language).toBe(expected) + }) + + it.each(['markdown', 'md', 'mdx', 'plaintext'])( + 'does NOT force a code block for VSCode %s copies (parses as markdown instead)', + (mode) => { + editor = mount() + const handled = paste(editor, '# Title\n\n- item', '', { + 'vscode-editor-data': JSON.stringify({ mode }), + }) + expect(handled).toBe(true) + const types = (editor.getJSON().content ?? []).map((n) => n.type) + expect(types).not.toContain('codeBlock') + expect(types).toContain('heading') + expect(types).toContain('bulletList') + } + ) + + it('strips
a
' + const cleaned = transformHtml(editor, gsheets) + expect(cleaned).not.toContain('