diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 0baeb6d70a1..d8765bb9fdd 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -66,38 +66,11 @@ opacity: 0; } -html[data-sidebar-collapsed] .sidebar-container span, -html[data-sidebar-collapsed] .sidebar-container .text-small { - opacity: 0; -} - .sidebar-container .sidebar-collapse-hide { transition: opacity 60ms ease; } -.sidebar-container .sidebar-collapse-show { - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease-out; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-hide, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide { - opacity: 0; -} - -.sidebar-container[data-collapsed] .sidebar-collapse-show, -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show { - opacity: 1; - pointer-events: auto; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove { - display: none; -} - -html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { - width: 0; +.sidebar-container[data-collapsed] .sidebar-collapse-hide { opacity: 0; } diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 82e6f107b77..4ab0bddef79 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -78,26 +78,36 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // window yields a width >= MIN instead of a sub-minimum sliver. var defaultSidebarWidth = 248; try { - var stored = localStorage.getItem('sidebar-state'); - if (stored) { - var parsed = JSON.parse(stored); - var state = parsed && parsed.state; - var isCollapsed = state && state.isCollapsed; - - if (isCollapsed) { - document.documentElement.style.setProperty('--sidebar-width', '51px'); - document.documentElement.setAttribute('data-sidebar-collapsed', ''); - } else { - var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); - var finalWidth = - typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 248), maxSidebarWidth) - : defaultSidebarWidth; - document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); - } + // Collapse comes from the cookie (independent of localStorage + // parsing); the persisted width is read defensively below. Match the + // value strictly so 'sidebar_collapsed=10' isn't read as collapsed. + var cookieMatch = document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/); + var hasCookie = cookieMatch !== null; + var collapsed = cookieMatch !== null && cookieMatch[1] === '1'; + + var state = null; + try { + var stored = localStorage.getItem('sidebar-state'); + state = stored ? JSON.parse(stored).state : null; + } catch (e) {} + + // One-time migration: seed the cookie from the legacy localStorage + // flag for users who collapsed before the cookie existed. + if (!hasCookie && state && typeof state.isCollapsed === 'boolean') { + collapsed = state.isCollapsed; + document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax'; + } + + if (collapsed) { + document.documentElement.style.setProperty('--sidebar-width', '51px'); } else { - document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); + var width = state && state.sidebarWidth; + var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3); + var finalWidth = + typeof width === 'number' && isFinite(width) + ? Math.min(Math.max(width, 248), maxSidebarWidth) + : defaultSidebarWidth; + document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px'); } } catch (e) { document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px'); diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index b544c525cae..f03a8cdcdf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps { veilBoundaryRef: React.RefObject } +/** + * Grace period before a hover-out dismisses the path popover. Covers the gap + * the pointer crosses between the trigger and the popover content (and brief + * jitter at their edges); re-entering either within this window cancels the + * close. Standard hover-intent close delay — not tied to any navigation timing. + */ +const POPOVER_CLOSE_DELAY_MS = 120 + function BreadcrumbLocationPopover({ icon: Icon, breadcrumbs, @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({ const closeTimeoutRef = useRef | null>(null) const rootBreadcrumb = breadcrumbs[0] - const openPopover = () => { + const cancelScheduledClose = () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } + } + + /** + * Hover-intent open. Driven only by pointer-/keyboard-enter — never by + * pointer movement. This is what makes the popover dismiss cleanly on a + * click-to-navigate: a stationary click fires no enter event, so once + * {@link navigateAndClose} sets `open` false nothing re-opens it before the + * route swaps. (A move-driven open would re-fire under the resting cursor and + * flash the popover/veil back in mid-navigation.) + */ + const openPopover = () => { + cancelScheduledClose() setOpen(true) } const scheduleClose = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - } + cancelScheduledClose() closeTimeoutRef.current = setTimeout(() => { setOpen(false) closeTimeoutRef.current = null - }, 120) + }, POPOVER_CLOSE_DELAY_MS) + } + + /** + * Closes the popover up front, then runs the crumb's handler. Closing first + * lets the veil fade and the popover play its exit animation instead of + * snapping away when navigation unmounts the header. + */ + const navigateAndClose = (onClick?: () => void) => { + if (!onClick) return + cancelScheduledClose() + setOpen(false) + onClick() } useEffect(() => { @@ -413,15 +443,11 @@ function BreadcrumbLocationPopover({ + )} + {!isMermaid && + (editor.isEditable ? ( + // Editable: a language picker. Read-only: a static label — selecting a language calls + // updateAttributes, which would mutate a doc that must not change. + + + + + + {LANGUAGE_OPTIONS.map((option) => ( + + updateAttributes({ language: option.value === PLAIN ? null : option.value }) + } + > + {option.label} + + ))} + + + ) : ( + + {label} + + ))} + {!isMermaid && ( + + )} + + +
+         as='code' />
+      
+ {showDiagram && ( + // Clicking the diagram selects the whole node (same selection ring as an image/code block) + // instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret, + // which would otherwise flip to source. Editing is an explicit Show source / blur action. +
{ + event.preventDefault() + const pos = typeof getPos === 'function' ? getPos() : null + if (typeof pos === 'number') editor.commands.setNodeSelection(pos) + }} + > + +
+ )} + + ) +} + +function codeBlockText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */ +function fenceFor(text: string): string { + const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length)) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +/** + * Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code + * block that itself contains a ``` line round-trips instead of shattering. Shared by the test + * (plain) and live ({@link CodeBlockWithLanguage}) paths. + */ +export const MarkdownCodeBlock = CodeBlock.extend({ + renderMarkdown: (node: JSONContent) => { + const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' + const text = codeBlockText(node) + const fence = fenceFor(text) + return `${fence}${language}\n${text}\n${fence}` + }, +}) + +/** + * Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The + * `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to + * the ```lang fence on save; wrap is a view-only preference. + */ +export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts new file mode 100644 index 00000000000..6b74e26da37 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { buildDecorations, changeTouchesCodeBlock } from './code-highlight' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null + +/** Position just inside the first code block in the current editor doc. */ +function codeBlockPos(ed: Editor): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos === -1 && node.type.name === 'codeBlock') pos = p + return pos === -1 + }) + if (pos === -1) throw new Error('no code block') + return pos +} + +function decorationClassesFor(markdown: string): string[] { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + const decorations = buildDecorations(editor.state.doc).find() + editor.destroy() + editor = null + return decorations.map( + (decoration) => + (decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class + ) +} + +afterEach(() => { + editor?.destroy() + editor = null +}) + +describe('code block syntax highlighting', () => { + it('emits Prism token decorations for a known language', () => { + const classes = decorationClassesFor('```js\nconst x = 1\n```') + expect(classes.length).toBeGreaterThan(0) + expect(classes.every((c) => c.startsWith('token'))).toBe(true) + expect(classes.some((c) => c.includes('keyword'))).toBe(true) + }) + + it('does not decorate plain prose', () => { + expect(decorationClassesFor('just some text')).toHaveLength(0) + }) + + it('does not decorate an unregistered language', () => { + expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0) + }) +}) + +describe('changeTouchesCodeBlock (incremental re-tokenization gate)', () => { + function mount(markdown: string): Editor { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor + } + + it('is false when an edit lands only in prose (decorations are mapped, not rebuilt)', () => { + const ed = mount('intro text\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('Z', 1) // inside the leading paragraph + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(false) + }) + + it('is true when an edit lands inside a code block (forces a re-tokenize)', () => { + const ed = mount('intro\n\n```js\nconst x = 1\n```') + const tr = ed.state.tr.insertText('y', codeBlockPos(ed) + 1) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) + + it('is true when the code block language changes via setNodeMarkup', () => { + const ed = mount('```js\nconst x = 1\n```') + const pos = codeBlockPos(ed) + const tr = ed.state.tr.setNodeMarkup(pos, undefined, { language: 'python' }) + expect(changeTouchesCodeBlock(tr, tr.doc)).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts new file mode 100644 index 00000000000..5609f56922b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight.ts @@ -0,0 +1,133 @@ +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import Prism, { type Token, type TokenStream } from 'prismjs' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-css' +import 'prismjs/components/prism-markup' +import 'prismjs/components/prism-javascript' +import 'prismjs/components/prism-typescript' +import 'prismjs/components/prism-yaml' +import 'prismjs/components/prism-sql' +import 'prismjs/components/prism-python' +import 'prismjs/components/prism-json' +import 'prismjs/components/prism-c' +import 'prismjs/components/prism-cpp' +import 'prismjs/components/prism-csharp' +import 'prismjs/components/prism-go' +import 'prismjs/components/prism-java' +import 'prismjs/components/prism-markup-templating' +import 'prismjs/components/prism-php' +import 'prismjs/components/prism-ruby' +import 'prismjs/components/prism-rust' +import { detectLanguage } from './detect-language' + +const HIGHLIGHT_PLUGIN_KEY = new PluginKey('codeBlockHighlight') + +function tokenClasses(token: Token): string { + const classes = ['token', token.type] + if (token.alias) classes.push(...(Array.isArray(token.alias) ? token.alias : [token.alias])) + return classes.join(' ') +} + +/** + * Walks Prism's token tree, emitting one inline decoration per token over its text range. + * Nested tokens stack (ProseMirror nests overlapping inline decorations), reproducing the + * `.token`-class structure Prism would render as HTML. + */ +function collectTokenDecorations( + stream: TokenStream, + base: number, + offset: { value: number }, + decorations: Decoration[], + limit: number +) { + const tokens = Array.isArray(stream) ? stream : [stream] + for (const token of tokens) { + if (typeof token === 'string') { + offset.value += token.length + continue + } + const start = offset.value + collectTokenDecorations(token.content, base, offset, decorations, limit) + const from = base + start + const to = Math.min(base + offset.value, limit) + if (to > from) decorations.push(Decoration.inline(from, to, { class: tokenClasses(token) })) + } +} + +export function buildDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + doc.descendants((node, pos) => { + if (node.type.name !== 'codeBlock') return + const language = (node.attrs.language as string | null) ?? detectLanguage(node.textContent) + const grammar = language ? Prism.languages[language] : undefined + if (!grammar) return + // Defensive: a malformed grammar or a token/position mismatch must never throw here — a throw + // in the decorations plugin blanks the whole editor. The `limit` clamps any over-long token. + try { + const base = pos + 1 + collectTokenDecorations( + Prism.tokenize(node.textContent, grammar), + base, + { value: 0 }, + decorations, + base + node.content.size + ) + } catch {} + }) + return DecorationSet.create(doc, decorations) +} + +/** + * Whether the transaction's changed ranges intersect any code block in the new doc — including + * a `setNodeMarkup` language change (whose step range covers the node). When false, the cheap + * path just maps existing decorations instead of re-tokenizing. + */ +export function changeTouchesCodeBlock(tr: Transaction, doc: ProseMirrorNode): boolean { + let touches = false + for (const map of tr.mapping.maps) { + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + if (touches) return + const from = Math.max(0, Math.min(newStart, doc.content.size)) + const to = Math.max(from, Math.min(newEnd, doc.content.size)) + doc.nodesBetween(from, to, (node) => { + if (node.type.name === 'codeBlock') touches = true + return !touches + }) + }) + } + return touches +} + +/** + * Syntax-highlights fenced code blocks with Prism, emitting the same `.token` classes the + * rest of the app uses so the `code-editor-theme` styles (light + dark) apply unchanged. + * Re-tokenizes only when a change actually touches a code block (typing in prose just maps + * the existing decorations), keeping the cost off the common keystroke path. + */ +export const CodeBlockHighlight = Extension.create({ + name: 'codeBlockHighlight', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: HIGHLIGHT_PLUGIN_KEY, + state: { + init: (_, { doc }) => buildDecorations(doc), + apply: (tr, current) => { + if (tr.steps.length === 0) return current + if (!changeTouchesCodeBlock(tr, tr.doc)) return current.map(tr.mapping, tr.doc) + return buildDecorations(tr.doc) + }, + }, + props: { + decorations(state) { + return HIGHLIGHT_PLUGIN_KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts new file mode 100644 index 00000000000..d3f830e2ee8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-languages.test.ts @@ -0,0 +1,21 @@ +/** + * @vitest-environment jsdom + * + * Guards against drift between the code-block language picker and the Prism grammars actually + * registered by CodeBlockHighlight: every selectable language must have a registered grammar, or it + * would silently fall back to no highlighting. + */ +import Prism from 'prismjs' +import { describe, expect, it } from 'vitest' +import { LANGUAGE_OPTIONS } from './code-block' +// Importing the highlighter registers all the prism-* grammars as a side effect. +import './code-highlight' + +describe('code-block languages', () => { + it('every selectable language has a registered Prism grammar', () => { + for (const { value } of LANGUAGE_OPTIONS) { + if (value === 'plain') continue + expect(Prism.languages[value], `no Prism grammar registered for "${value}"`).toBeDefined() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts new file mode 100644 index 00000000000..a5c9194a7f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { detectLanguage } from './detect-language' + +describe('detectLanguage', () => { + it('returns null for empty or unrecognizable content', () => { + expect(detectLanguage('')).toBeNull() + expect(detectLanguage(' \n ')).toBeNull() + expect(detectLanguage('just some prose words here')).toBeNull() + }) + + it('detects common languages from content shape', () => { + expect(detectLanguage('{\n "a": 1,\n "b": [2, 3]\n}')).toBe('json') + expect(detectLanguage('const x = 1\nfunction go() {}')).toBe('javascript') + expect(detectLanguage('interface Foo { name: string }')).toBe('typescript') + expect(detectLanguage('def main():\n print("hi")')).toBe('python') + expect(detectLanguage('SELECT id FROM users WHERE id = 1')).toBe('sql') + expect(detectLanguage('#!/bin/bash\necho hello')).toBe('bash') + expect(detectLanguage('
hi
')).toBe('markup') + expect(detectLanguage('.btn { color: red; padding: 4px }')).toBe('css') + }) + + it('does not misclassify a JS object as JSON', () => { + expect(detectLanguage('const x = { a: 1 }')).toBe('javascript') + }) + + it('detects Go, Rust, Java', () => { + expect(detectLanguage('package main\n\nfunc main() {\n\tfmt.Println("hi")\n}')).toBe('go') + expect(detectLanguage('type User struct {\n\tName string\n}')).toBe('go') + expect(detectLanguage('fn main() {\n let mut x = 1;\n println!("{}", x);\n}')).toBe('rust') + expect(detectLanguage('public class Box {\n private int n;\n}')).toBe('java') + }) + + it('does not misread generics as HTML markup', () => { + expect(detectLanguage('public class Box { private List items; }')).toBe('java') + expect(detectLanguage('let v: Vec = Vec::new();\nfn f() {}')).toBe('rust') + expect(detectLanguage('func Map[T any](s []T) {}\npackage x')).toBe('go') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts new file mode 100644 index 00000000000..d391ed13d29 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language.ts @@ -0,0 +1,63 @@ +/** + * Heuristic language detection for a fenced code block that has no explicit ` ```lang ` tag. + * Used only to drive syntax highlighting + the picker label — the detected value is NEVER + * written back to the markdown, so opening a file never mutates it. Restricted to the grammars + * {@link CodeBlockHighlight} actually registers with Prism; returns `null` when unsure. + */ +const DETECTORS: ReadonlyArray<{ language: string; test: RegExp }> = [ + // Real HTML: a closing tag, an opening tag with an attribute, or a doctype/comment. Deliberately + // NOT a bare `` so generics (`List`, `Vec`) aren't misread as markup. + { language: 'markup', test: /<\/[a-z][\w-]*\s*>|<[a-z][\w-]*\s+[\w:-]+=||console\.\w+|\brequire\(|\bexport\s+(default|const)\b/, + }, + { language: 'css', test: /[.#]?[\w-]+\s*\{[^}]*[\w-]+\s*:[^};]+;?[^}]*\}/ }, + { language: 'yaml', test: /^[\w-]+:\s+\S/m }, +] + +function looksLikeJson(sample: string): boolean { + const trimmed = sample.trim() + if (!/^[[{]/.test(trimmed)) return false + try { + JSON.parse(trimmed) + return true + } catch { + return false + } +} + +export function detectLanguage(code: string): string | null { + const sample = code.slice(0, 2000) + if (!sample.trim()) return null + if (looksLikeJson(sample)) return 'json' + for (const { language, test } of DETECTORS) { + if (test.test(sample)) return language + } + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts new file mode 100644 index 00000000000..870907a9a38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-signal.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment jsdom + * + * The rich editor uses TipTap's initial-content model: opening a file loads its markdown as the + * editor's initial `content`, which must NOT emit an update — so a freshly opened file is never + * marked dirty (no spurious autosave / "unsaved changes"). Only a genuine edit emits, which is what + * flips the dirty/autosave state on. These two cases guard exactly that contract. + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from './extensions' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(content: string, onUpdate: () => void): Editor { + return new Editor({ + extensions: createMarkdownContentExtensions(), + content, + contentType: 'markdown', + onUpdate, + }) +} + +describe('rich markdown editor — dirty signal', () => { + it('opening a file emits no update (never dirty on open), including markdown that normalizes', () => { + // A trailing newline and `_emphasis_` both normalize on serialization; opening must still be clean. + let updates = 0 + editor = mount('# Title\n\nsome _emphasis_ here\n', () => { + updates++ + }) + expect(updates).toBe(0) + expect(editor.isEmpty).toBe(false) + }) + + it('opening an empty file emits no update and is editable', () => { + let updates = 0 + editor = mount('', () => { + updates++ + }) + expect(updates).toBe(0) + }) + + it('a genuine edit emits an update (marks dirty → triggers autosave)', () => { + let updates = 0 + editor = mount('hello', () => { + updates++ + }) + editor.commands.insertContent(' world') + expect(updates).toBeGreaterThan(0) + }) +}) 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 new file mode 100644 index 00000000000..21921981774 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -0,0 +1,113 @@ +import type { Extensions, JSONContent, MarkdownRendererHelpers } from '@tiptap/core' +import { Code } from '@tiptap/extension-code' +import { TaskItem, TaskList } from '@tiptap/extension-list' +import Placeholder from '@tiptap/extension-placeholder' +import { + renderTableToMarkdown, + Table, + TableCell, + TableHeader, + TableRow, +} from '@tiptap/extension-table' +import { Markdown } from '@tiptap/markdown' +import StarterKit from '@tiptap/starter-kit' +import { CodeBlockWithLanguage, MarkdownCodeBlock } from './code-block' +import { CodeBlockHighlight } from './code-highlight' +import { MarkdownImage, ResizableImage } from './image' +import { RichMarkdownKeymap } from './keymap' +import { MarkdownLinkInputRule } from './link-input-rule' +import { MarkdownPaste } from './markdown-paste' +import { SlashCommand } from './slash-command/slash-command' + +/** + * Inline code that can combine with bold/italic/strike (GFM permits `**`x`**`, `~~`x`~~`). + * The stock Code mark sets `excludes: '_'`, which blocks every other mark from coexisting and + * makes the bubble-menu toggles silently no-op over a code selection. + */ +const InlineCode = Code.extend({ excludes: '' }) + +/** + * Table that escapes interior `|` characters when serializing cells. The upstream serializer + * joins cells with `|` without escaping, so a cell containing a literal pipe silently splits + * into phantom columns on round-trip (data loss). Escaping must happen on the `table` node — + * `tableCell`/`tableHeader` have no markdown renderer; the table renders cell children directly. + * + * The upstream serializer also wraps the table in its own leading/trailing blank lines; left in, + * the block joiner adds another, so an interior table churns its surrounding whitespace to + * `\n\n\n` on the first edit. Trimming the table's own output lets the joiner own the single + * blank-line separator — without touching blank lines inside fenced code (those live in the code + * node's text, not here). + */ +const PipeSafeTable = Table.extend({ + renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) => + renderTableToMarkdown(node, { + ...h, + renderChildren: (nodes, separator) => + h.renderChildren(nodes, separator).replace(/\|/g, '\\|'), + }) + .replace(/^\n+/, '') + .replace(/\n+$/, ''), +}) + +interface MarkdownEditorExtensionOptions { + placeholder: string +} + +interface ContentExtensionOptions { + /** Use the React node views (code-block language picker, image resize). Off for headless tests. */ + nodeViews?: boolean +} + +/** + * The schema + serialization extensions: the nodes/marks the document can contain and the + * Markdown ⇄ ProseMirror conversion. `StarterKit` provides core nodes/marks and the + * Markdown-style input rules (`# `, `- `, `**bold**`, …); `TaskList`/`TaskItem` add + * `- [ ]` checklists; `TableKit` adds GFM tables; `Markdown` serializes back to markdown. + * + * The code block is the standalone `CodeBlock` so the live editor can swap in a node view; + * the schema and markdown output are identical either way. + */ +export function createMarkdownContentExtensions({ + nodeViews = false, +}: ContentExtensionOptions = {}): Extensions { + const codeBlock = (nodeViews ? CodeBlockWithLanguage : MarkdownCodeBlock).configure({ + HTMLAttributes: { class: 'code-editor-theme' }, + }) + return [ + StarterKit.configure({ + link: { openOnClick: false }, + underline: false, + codeBlock: false, + code: false, + }), + InlineCode, + codeBlock, + (nodeViews ? ResizableImage : MarkdownImage).configure({ allowBase64: true }), + TaskList, + TaskItem.configure({ nested: true }), + PipeSafeTable.configure({ resizable: true }), + TableRow, + TableHeader, + TableCell, + MarkdownLinkInputRule, + Markdown, + ] +} + +/** + * The full extension set for the live editor: the content extensions plus the UI-only + * extensions — `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), and + * `Placeholder`. + */ +export function createMarkdownEditorExtensions({ + placeholder, +}: MarkdownEditorExtensionOptions): Extensions { + return [ + ...createMarkdownContentExtensions({ nodeViews: true }), + CodeBlockHighlight, + SlashCommand, + RichMarkdownKeymap, + MarkdownPaste, + Placeholder.configure({ placeholder }), + ] +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts new file mode 100644 index 00000000000..45a0cb92ae6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from './extensions' +import { findHeadingPos, slugifyHeading } from './heading-anchors' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */ +function docOf(markdown: string) { + editor = new Editor({ extensions: createMarkdownContentExtensions() }) + editor.commands.setContent(markdown, { contentType: 'markdown' }) + return editor.state.doc +} + +describe('slugifyHeading', () => { + it('lowercases, drops punctuation, and hyphenates whitespace (GitHub-style)', () => { + expect(slugifyHeading('Getting Started')).toBe('getting-started') + expect(slugifyHeading('API Reference!')).toBe('api-reference') + expect(slugifyHeading(' Spaced Out ')).toBe('spaced-out') + expect(slugifyHeading('Node.js & Bun')).toBe('nodejs-bun') + }) + + it('returns an empty string for punctuation-only text', () => { + expect(slugifyHeading('!!!')).toBe('') + expect(slugifyHeading('')).toBe('') + }) +}) + +describe('findHeadingPos', () => { + it('resolves a fragment slug to its heading position', () => { + const doc = docOf('# Intro\n\ntext\n\n## Getting Started\n\nmore') + expect(findHeadingPos(doc, 'intro')).toBeGreaterThanOrEqual(0) + expect(findHeadingPos(doc, 'getting-started')).toBeGreaterThan(findHeadingPos(doc, 'intro')) + }) + + it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => { + const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc') + const first = findHeadingPos(doc, 'notes') + const second = findHeadingPos(doc, 'notes-1') + const third = findHeadingPos(doc, 'notes-2') + expect(first).toBeGreaterThanOrEqual(0) + expect(second).toBeGreaterThan(first) + expect(third).toBeGreaterThan(second) + }) + + it('returns -1 when no heading matches', () => { + const doc = docOf('# Only Heading\n\nbody') + expect(findHeadingPos(doc, 'missing')).toBe(-1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts new file mode 100644 index 00000000000..677964d65e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts @@ -0,0 +1,36 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' + +/** + * Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so + * that `[label](#slug)` fragment links — written against how GitHub renders the same markdown — + * resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview. + */ +export function slugifyHeading(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') +} + +/** + * The document position of the heading a `#slug` fragment link targets, or -1 if none matches. + * Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate + * slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, … + */ +export function findHeadingPos(doc: ProseMirrorNode, slug: string): number { + const seen = new Map() + let found = -1 + doc.descendants((node, pos) => { + if (found >= 0) return false + if (node.type.name !== 'heading') return true + const base = slugifyHeading(node.textContent) + if (!base) return true + const n = seen.get(base) ?? 0 + seen.set(base, n + 1) + if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos + return found < 0 + }) + return found +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts new file mode 100644 index 00000000000..766e4c77ef6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { extractImageFiles } from './image-paste' + +function imageFile(name = 'shot.png'): File { + return new File([''], name, { type: 'image/png' }) +} + +function transfer( + files: File[], + items: Array<{ kind: string; type: string; file: File | null }> = [] +): DataTransfer { + return { + files, + items: items.map((entry) => ({ + kind: entry.kind, + type: entry.type, + getAsFile: () => entry.file, + })), + } as unknown as DataTransfer +} + +describe('extractImageFiles', () => { + it('returns nothing for a null payload or non-image files', () => { + expect(extractImageFiles(null)).toEqual([]) + expect(extractImageFiles(transfer([new File([''], 'a.txt', { type: 'text/plain' })]))).toEqual( + [] + ) + }) + + it('reads images from the files list (drag-drop)', () => { + const file = imageFile() + expect(extractImageFiles(transfer([file]))).toEqual([file]) + }) + + it('falls back to items when files is empty (pasted screenshot)', () => { + const file = imageFile() + const result = extractImageFiles(transfer([], [{ kind: 'file', type: 'image/png', file }])) + expect(result).toEqual([file]) + }) + + it('ignores non-file and non-image items', () => { + const result = extractImageFiles( + transfer( + [], + [ + { kind: 'string', type: 'text/plain', file: null }, + { kind: 'file', type: 'application/pdf', file: new File([''], 'a.pdf') }, + ] + ) + ) + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts new file mode 100644 index 00000000000..ff72fededf9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts @@ -0,0 +1,14 @@ +/** + * Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to + * `items` — many browsers expose a pasted or copied image (e.g. a screenshot) only through + * `DataTransfer.items` with an empty `files` list, so reading `files` alone misses them. + */ +export function extractImageFiles(transfer: DataTransfer | null): File[] { + if (!transfer) return [] + const fromFiles = Array.from(transfer.files).filter((file) => file.type.startsWith('image/')) + if (fromFiles.length > 0) return fromFiles + return Array.from(transfer.items) + .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts new file mode 100644 index 00000000000..41e2f888408 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -0,0 +1,27 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { resolveDisplaySrc } from './image' + +describe('resolveDisplaySrc', () => { + it('rewrites an in-app workspace file path to its serving endpoint (display only)', () => { + expect(resolveDisplaySrc('/workspace/W1/files/F123')).toBe('/api/files/view/F123') + expect(resolveDisplaySrc('/workspace/any-ws-id/files/abc-def')).toBe('/api/files/view/abc-def') + }) + + it('leaves absolute and non-workspace URLs untouched', () => { + expect(resolveDisplaySrc('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') + expect(resolveDisplaySrc('http://localhost/workspace/W1/files/F1')).toBe( + 'http://localhost/workspace/W1/files/F1' + ) + expect(resolveDisplaySrc('/other/path/files/x')).toBe('/other/path/files/x') + expect(resolveDisplaySrc('relative/image.png')).toBe('relative/image.png') + }) + + it('passes through empty/undefined and unparseable values', () => { + expect(resolveDisplaySrc(undefined)).toBeUndefined() + expect(resolveDisplaySrc('')).toBe('') + expect(resolveDisplaySrc('/workspace/W1/files/')).toBe('/workspace/W1/files/') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx new file mode 100644 index 00000000000..8e76a4244bb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from 'react' +import type { JSONContent } from '@tiptap/core' +import { Image } from '@tiptap/extension-image' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { normalizeLinkHref } from './markdown-fidelity' + +const MIN_WIDTH = 64 + +/** + * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical + * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an + * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize + * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it + * round-trips losslessly (and the file stays editable rather than opening read-only). + */ +const LINKED_IMAGE_RE = + /^\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/ + +/** Escape a value for safe interpolation into a double-quoted HTML attribute. */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +/** + * Rewrite an in-app workspace file path (`/workspace/{id}/files/{fileId}`) to its serving endpoint + * (`/api/files/view/{fileId}`) for display only — the stored `src` attribute keeps the original path + * so markdown round-trips unchanged. Absolute and non-workspace URLs pass through untouched. + */ +export function resolveDisplaySrc(src: string | undefined): string | undefined { + if (!src) return src + try { + const parsed = new URL(src, 'http://placeholder') + if (parsed.origin !== 'http://placeholder') return src + const [, seg1, , seg3, fileId] = parsed.pathname.split('/') + if (seg1 === 'workspace' && seg3 === 'files' && fileId) return `/api/files/view/${fileId}` + } catch { + // not a parseable URL — render as-is + } + return src +} + +/** + * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when + * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to + * preserve its dimensions. Unsized images stay clean `![alt](src)`. An image with an `href` is + * wrapped in a markdown link so a linked badge round-trips as `[![alt](src)](href)`. + */ +function imageMarkdown(node: JSONContent): string { + const attrs = node.attrs ?? {} + const src = typeof attrs.src === 'string' ? attrs.src : '' + const alt = typeof attrs.alt === 'string' ? attrs.alt : '' + const title = typeof attrs.title === 'string' ? attrs.title : '' + const href = typeof attrs.href === 'string' ? attrs.href : '' + const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' + const width = attrs.width + const height = attrs.height + let image: string + if (width || height) { + const parts = [`src="${escapeAttr(src)}"`] + if (alt) parts.push(`alt="${escapeAttr(alt)}"`) + if (title) parts.push(`title="${escapeAttr(title)}"`) + if (width) parts.push(`width="${escapeAttr(String(width))}"`) + if (height) parts.push(`height="${escapeAttr(String(height))}"`) + image = `` + } else { + // Escape so an alt with `]`/`[` or a title with `"` can't break out of the `![…](… "…")` syntax + // and corrupt the round-trip; a src with spaces/parens goes in angle brackets (CommonMark). + const titlePart = title ? ` "${title.replace(/["\\]/g, '\\$&')}"` : '' + const safeSrc = /[\s()]/.test(src) ? `<${src}>` : src + image = `![${alt.replace(/[\\[\]]/g, '\\$&')}](${safeSrc}${titlePart})` + } + if (!href) return image + const hrefTitlePart = hrefTitle ? ` "${hrefTitle}"` : '' + return `[${image}](${href}${hrefTitlePart})` +} + +interface MarkdownImageToken { + /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ + src?: string + alt?: string + title?: string | null + /** Built-in image token holds the source URL here; our linked token holds the link target. */ + href?: string + hrefTitle?: string | null + /** Built-in image token holds the alt text here. */ + text?: string +} + +/** Map both the built-in image token and our linked-image token onto the image node's attributes. */ +function parseImageToken(token: MarkdownImageToken): JSONContent { + const isLinked = typeof token.src === 'string' + return { + type: 'image', + attrs: isLinked + ? { + src: token.src, + alt: token.alt ?? '', + title: token.title ?? null, + href: token.href ?? null, + hrefTitle: token.hrefTitle ?? null, + } + : { + src: token.href ?? '', + alt: token.text ?? '', + title: token.title ?? null, + href: null, + hrefTitle: null, + }, + } +} + +const widthAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('width'), + renderHTML: (attributes: Record) => + attributes.width ? { width: String(attributes.width) } : {}, +} + +const heightAttr = { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('height'), + renderHTML: (attributes: Record) => + attributes.height ? { height: String(attributes.height) } : {}, +} + +/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ +const hrefAttr = { default: null, rendered: false } +const hrefTitleAttr = { default: null, rendered: false } + +/** + * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an + * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless + * round-trip path (no node view) and the live {@link ResizableImage}. + */ +export const MarkdownImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: widthAttr, + height: heightAttr, + href: hrefAttr, + hrefTitle: hrefTitleAttr, + } + }, + markdownTokenizer: { + name: 'image', + level: 'inline', + start: (src: string) => src.indexOf('[!['), + tokenize: (src: string): (MarkdownImageToken & { type: string; raw: string }) | undefined => { + const match = LINKED_IMAGE_RE.exec(src) + if (!match) return undefined + return { + type: 'image', + raw: match[0], + alt: match[1] ?? '', + src: match[2], + title: match[3] ?? null, + href: match[4], + hrefTitle: match[5] ?? null, + } + }, + }, + parseMarkdown: parseImageToken, + renderMarkdown: imageMarkdown, +}) + +/** + * Drag-to-resize image node view (handle at the bottom-right, revealed on selection). Dragging + * commits the new pixel width to the `width` attribute, which serializes to ``. + */ +function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) { + const imageRef = useRef(null) + const dragAbortRef = useRef(null) + const [dragging, setDragging] = useState(false) + const attrs = node.attrs as { + src?: string + alt?: string + title?: string + width?: string | null + href?: string | null + } + + useEffect(() => () => dragAbortRef.current?.abort(), []) + + const startResize = (event: React.PointerEvent) => { + event.preventDefault() + const image = imageRef.current + if (!image) return + const startX = event.clientX + const startWidth = image.offsetWidth + setDragging(true) + dragAbortRef.current?.abort() + const controller = new AbortController() + dragAbortRef.current = controller + const { signal } = controller + + window.addEventListener( + 'pointermove', + (move) => { + const next = Math.max(MIN_WIDTH, Math.round(startWidth + (move.clientX - startX))) + updateAttributes({ width: String(next) }) + }, + { signal } + ) + window.addEventListener( + 'pointerup', + () => { + setDragging(false) + controller.abort() + }, + { signal } + ) + } + + const widthStyle = attrs.width + ? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width } + : undefined + + // Sanitize the linked-image target before rendering the anchor — a parsed markdown href is + // untrusted and could be `javascript:`/`data:`; an unsafe value drops the link (image only). + const safeHref = normalizeLinkHref(typeof attrs.href === 'string' ? attrs.href : '') + + // Read-only: no drag-to-reorder and no resize handle — both call updateAttributes / dispatch a move, + // mutating a doc that must not change. The image still renders (and follows its link on click). + const editable = editor.isEditable + + const image = ( + {attrs.alt + ) + + return ( + + {safeHref ? ( + // The editor's handleClick is the sole navigator (gated on editable/modifier, like text links + // via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode + // places the caret / selects the node instead of opening a tab. + event.preventDefault()} + > + {image} + + ) : ( + image + )} + {editable && (selected || dragging) && ( +