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
Expand Up @@ -15,6 +15,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap
import { Check, ChevronDown, Code, Copy, Eye, WrapText } from 'lucide-react'
import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram'
import { detectLanguage } from './detect-language'
import { useEditorEditable } from './use-editor-editable'

const PLAIN = 'plain'
const MERMAID = 'mermaid'
Expand Down Expand Up @@ -59,6 +60,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
const [editingInline, setEditingInline] = useState(false)
const [peekSource, setPeekSource] = useState(false)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const editable = useEditorEditable(editor)

const explicitLanguage = node.attrs.language as string | null
const text = node.textContent
Expand All @@ -68,7 +70,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
// diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
// focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
useEffect(() => {
if (!isMermaid || !editor.isEditable) {
if (!isMermaid || !editable) {
setEditingInline(false)
return
}
Expand All @@ -91,9 +93,9 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
editor.off('focus', sync)
editor.off('blur', sync)
}
}, [editor, getPos, isMermaid])
}, [editor, getPos, isMermaid, editable])

const showSource = editor.isEditable ? editingInline : peekSource
const showSource = editable ? editingInline : peekSource
const showDiagram = isMermaid && text.trim().length > 0 && !showSource

// Skip language detection on the mermaid path — the picker/label never render there.
Expand All @@ -104,7 +106,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
'Plain text'

const toggleSource = () => {
if (!editor.isEditable) {
if (!editable) {
setPeekSource((value) => !value)
return
}
Expand Down Expand Up @@ -144,7 +146,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
</button>
)}
{!isMermaid &&
(editor.isEditable ? (
(editable ? (
// Editable: a language picker. Read-only: a static label — selecting a language calls
// updateAttributes, which would mutate a doc that must not change.
<DropdownMenu onOpenChange={setMenuOpen}>
Expand Down Expand Up @@ -179,7 +181,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
{label}
</span>
))}
{!isMermaid && editor.isEditable && (
{!isMermaid && editable && (
<button
type='button'
aria-label='Toggle line wrap'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Plugin } from '@tiptap/pm/state'
* (`=(?!=)`) 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_BODY = `(?:[^=]|=(?!=))+?`
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)==)$`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Image } from '@tiptap/extension-image'
import { NodeSelection, Plugin } from '@tiptap/pm/state'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useFileContentSource } from '@/hooks/use-file-content-source'
import { normalizeLinkHref } from './markdown-fidelity'
import { useEditorEditable } from './use-editor-editable'

const MIN_WIDTH = 64

Expand Down Expand Up @@ -168,7 +170,12 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
const source = useFileContentSource()
const imageRef = useRef<HTMLImageElement>(null)
const dragAbortRef = useRef<AbortController | null>(null)
const dragWidthRef = useRef<number | null>(null)
const [dragging, setDragging] = useState(false)
/** Live width during a resize drag; kept out of the doc so the whole resize is one undo step. */
const [dragWidth, setDragWidth] = useState<number | null>(null)
/** Whether the current src failed to load; reset on src change so a retried/edited src can load. */
const [failed, setFailed] = useState(false)
const attrs = node.attrs as {
src?: string
alt?: string
Expand All @@ -178,6 +185,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
}

useEffect(() => () => dragAbortRef.current?.abort(), [])
useEffect(() => setFailed(false), [attrs.src])

const startResize = (event: React.PointerEvent) => {
event.preventDefault()
Expand All @@ -195,31 +203,42 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
'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()
dragWidthRef.current = next
setDragWidth(next)
},
{ signal }
)
const finish = () => {
const finalWidth = dragWidthRef.current
setDragging(false)
setDragWidth(null)
dragWidthRef.current = null
controller.abort()
if (finalWidth !== null) updateAttributes({ width: String(finalWidth) })
}
window.addEventListener('pointerup', finish, { signal })
window.addEventListener('pointercancel', finish, { signal })
}

const widthStyle = attrs.width
? { width: /^\d+$/.test(attrs.width) ? `${attrs.width}px` : attrs.width }
const committedWidth = attrs.width
? /^\d+$/.test(attrs.width)
? `${attrs.width}px`
: attrs.width
: undefined
const widthStyle =
dragWidth !== null
? { width: `${dragWidth}px` }
: committedWidth
? { width: committedWidth }
: 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 editable = useEditorEditable(editor)

const image = (
<img
Expand All @@ -233,9 +252,13 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
draggable={editable}
data-drag-handle={editable ? '' : undefined}
style={widthStyle}
onError={() => setFailed(true)}
onLoad={() => setFailed(false)}
className={cn(
'block max-w-full rounded-lg border border-[var(--border)]',
editable && 'cursor-grab'
editable && 'cursor-grab',
failed &&
'min-h-[72px] min-w-[140px] bg-[var(--surface-5)] p-3 text-[var(--text-muted)] text-caption'
)}
/>
)
Expand Down Expand Up @@ -275,4 +298,28 @@ export const ResizableImage = MarkdownImage.extend({
addNodeView() {
return ReactNodeViewRenderer(ResizableImageView)
},
/**
* Guarantee a plain click on the image forms a node selection. The image body is also a native drag
* source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on
* drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes
* with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526).
* Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and
* modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's
* `handleClick` / default behavior.
*/
addProseMirrorPlugins() {
const nodeName = this.name
return [
new Plugin({
props: {
handleClickOn(view, _pos, node, nodePos, event) {
if (!view.editable || node.type.name !== nodeName) return false
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
return true
Comment thread
waleedlatif1 marked this conversation as resolved.
},
},
}),
]
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ const BOM = '\uFEFF'
const FRONTMATTER_REGEX = /^---\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n)*/
const ESCAPED_CALLOUT_REGEX = /^(\s*>(?:\s*>)*\s*)\\\[!([A-Za-z]+)\\\]/gm

/**
* Alternates a code region (fenced block or inline span \u2014 never rewritten) with an inline link whose
* destination has no title and isn't angle-bracketed. The code branch is listed first so a link inside
* code is consumed as code and left untouched. The destination stops at `)` / whitespace, so a link
* carrying a title (`[x](url "t")`) never matches and is preserved verbatim.
*/
const CODE_OR_PLAIN_LINK_REGEX =
/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)|\[([^\]]+)]\(([^)\s<>]+)\)/g
const HTTP_URL_REGEX = /^https?:\/\/\S+$/i

/**
* Collapses an autolinked destination back to its bare form: our normalizing serializer rewrites a bare
* URL or `<url>` autolink to `[url](url)` and a bare email to `[a@b.com](mailto:a@b.com)`, which churns
* every README's links into explicit-link syntax on the first save. When the visible text already equals
* the destination (a plain `http(s)` URL, or an email behind `mailto:`), GFM re-autolinks the bare form,
* so emitting it round-trips identically with a far quieter diff. Links inside code and titled links are
* left untouched (see {@link CODE_OR_PLAIN_LINK_REGEX}).
*/
function collapseAutolinkedUrls(markdown: string): string {
return markdown.replace(CODE_OR_PLAIN_LINK_REGEX, (match, code, text, href) => {
if (code) return code
if (text === href && HTTP_URL_REGEX.test(href)) return href
if (href === `mailto:${text}`) return text
return match
})
}

export interface SplitMarkdown {
/** Out-of-band leading prefix (a BOM and/or the frontmatter block), byte-exact, or `''`. */
frontmatter: string
Expand Down Expand Up @@ -87,5 +114,8 @@ export function normalizeLinkHref(href: string): string {
* begins with whitespace.
*/
export function postProcessSerializedMarkdown(markdown: string): string {
return markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]').replace(/\n+$/, '\n')
return collapseAutolinkedUrls(markdown.replace(ESCAPED_CALLOUT_REGEX, '$1[!$2]')).replace(
/\n+$/,
'\n'
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,63 @@ describe('markdown paste', () => {
expect(transformHtml(editor, '<script>x<script>y</script>')).toBe('')
})
})

describe('linkify a selection on URL paste', () => {
function linkify(
pasted: string,
from = 1,
to = 10
): { handled: boolean; href?: string; text: string } {
editor = mount()
editor.commands.setContent('select me here', { contentType: 'markdown' })
editor.commands.setTextSelection({ from, to })
const handled = paste(editor, pasted)
return {
handled,
href: JSON.stringify(editor.getJSON()).match(/"href":"([^"]+)"/)?.[1],
text: editor.getText(),
}
}

it('wraps a non-empty text selection in a link when a URL is pasted (keeping the text)', () => {
const r = linkify('https://sim.ai')
expect(r.handled).toBe(true)
expect(r.href).toBe('https://sim.ai')
expect(r.text).toBe('select me here')
})

it('prepends https:// to a bare www host and mailto: to a bare email', () => {
expect(linkify('www.sim.ai').href).toBe('https://www.sim.ai')
expect(linkify('a@b.com').href).toBe('mailto:a@b.com')
})

it('does not linkify a collapsed caret (empty selection)', () => {
const r = linkify('https://sim.ai', 5, 5)
expect(r.handled).toBe(false)
})

it('does not linkify a multi-word paste over a selection', () => {
expect(linkify('not a url just words').handled).toBe(false)
})

it('does not linkify an unsafe javascript: url', () => {
const r = linkify('javascript:alert(1)')
expect(r.handled).toBe(false)
expect(r.href).toBeUndefined()
})

it('links a real mailto: but not a crafted mailto: payload', () => {
expect(linkify('mailto:a@b.com').href).toBe('mailto:a@b.com')
const crafted = linkify('mailto:javascript:alert(1)')
expect(crafted.handled).toBe(false)
expect(crafted.href).toBeUndefined()
})

it('does not linkify a selection spanning multiple blocks', () => {
editor = mount()
editor.commands.setContent('alpha\n\nbeta', { contentType: 'markdown' })
editor.commands.setTextSelection({ from: 3, to: 9 })
expect(paste(editor, 'https://sim.ai')).toBe(false)
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"link"')
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
import { Extension } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { normalizeLinkHref } from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'

/**
* A single link the paste can wrap a selection in: an http(s) URL, a `mailto:` to a real address, a bare
* `www.` host, or a bare email. `mailto:` requires an actual `user@host.tld` payload so a crafted value
* like `mailto:javascript:…` (no `@`) never matches and falls through to a normal paste.
*/
const HTTP_URL = /^https?:\/\/\S+$/i
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const MAILTO_URL = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i
const BARE_WWW = /^www\.\S+\.\S+$/i

/**
* If pasted text is a single link, return the href to wrap a selection in — `www.` gets `https://`, a
* bare email gets `mailto:`. Returns null for anything else (a multi-word or non-URL paste falls through
* to normal insertion). The caller still runs the result through `normalizeLinkHref` for scheme safety.
*/
function pastedLinkHref(text: string): string | null {
if (HTTP_URL.test(text) || MAILTO_URL.test(text)) return text
if (BARE_WWW.test(text)) return `https://${text}`
if (EMAIL.test(text)) return `mailto:${text}`
return null
}

/**
* Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge,
* list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully
Expand Down Expand Up @@ -143,11 +166,21 @@ export const MarkdownPaste = Extension.create({
new Plugin({
props: {
transformPastedHTML: (html) => stripNonContentHtml(html),
handlePaste: (_view, event) => {
handlePaste: (view, event) => {
if (!editor.isEditable) return false
if (editor.isActive('codeBlock') || editor.isActive('code')) return false
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
const { selection } = view.state
if (
!selection.empty &&
selection.$from.sameParent(selection.$to) &&
selection.$from.parent.inlineContent
) {
const href = pastedLinkHref(text.trim())
const safeHref = href ? normalizeLinkHref(href) : ''
if (safeHref) return editor.commands.setLink({ href: safeHref })
}
const language = parseVscodeLanguage(event.clipboardData?.getData('vscode-editor-data'))
if (language) {
return editor.commands.insertContent({
Expand Down
Loading
Loading