mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(rich-markdown-editor): reliable image selection + serialization/paste polish (#5590)
* fix(rich-markdown-editor): reliable image selection + resize and broken-image polish - Reactive editability. The editor runs with shouldRerenderOnTransaction:false, so a node view that read editor.isEditable once at render kept a stale value after setEditable() toggled (e.g. an agent stream settling into the doc), leaving a pasted image showing read-only affordances and code blocks stuck on their read-only label until a full refresh. A shared useEditorEditable hook subscribes to the editor's update/transaction events so both node views track editability reactively. - Deterministic click-to-select. A handleClickOn plugin sets the image's NodeSelection on a plain click so selecting never depends on ProseMirror's click-vs-drag arbitration; grab-anywhere drag-reorder is kept, and modified clicks (Cmd/Ctrl to follow a linked badge) fall through. - Resize commits once. The width previews in local state during the drag and commits to the node once on pointer-up (or pointer-cancel), so a resize is a single undo step and an interrupted drag isn't lost. - Broken-image placeholder. A src that fails to load renders as a visible box with its alt text and stays selectable, instead of collapsing to a bare broken-icon. * fix(rich-markdown-editor): keep bare URLs and autolinks bare on serialize The normalizing serializer rewrote a bare URL or <url>/<email> autolink to [url](url) / [a@b.com](mailto:a@b.com) on every save, churning every README's links. postProcessSerializedMarkdown now collapses a link back to its bare form when the visible text already equals the destination (a plain http(s) URL, or an email behind mailto:) — GFM re-autolinks it, so the round-trip is identical with a far quieter diff. Titled links, explicit links, and any link inside a fenced/inline code region are left untouched. Idempotent. * feat(rich-markdown-editor): linkify a selection when a URL is pasted over it Pasting a single URL (or a bare www. host / email) over a non-empty text selection within one block now wraps the selection in a link, keeping the visible text. www. gets https://, an email gets mailto:, and the href is scheme-sanitized (javascript:/data: rejected; mailto: requires a real user@host address). Collapsed carets, cross-block selections, multi-word pastes, node selections, and code contexts fall through to normal paste. * chore(rich-markdown-editor): drop useless String.raw in highlight.ts biome 2.0's noUselessStringRaw flags HIGHLIGHT_BODY — its pattern has no escape sequences, so String.raw is equivalent to a plain template literal (byte-identical value; interpolated into the other String.raw regexes unchanged). Pre-existing on staging; the repo-wide lint gate blocks CI on it.
This commit is contained in:
+8
-6
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -104,7 +106,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
|
||||
'Plain text'
|
||||
|
||||
const toggleSource = () => {
|
||||
if (!editor.isEditable) {
|
||||
if (!editable) {
|
||||
setPeekSource((value) => !value)
|
||||
return
|
||||
}
|
||||
@@ -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}>
|
||||
@@ -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'
|
||||
|
||||
+1
-1
@@ -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)==)$`)
|
||||
|
||||
+60
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -195,23 +203,34 @@ 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).
|
||||
@@ -219,7 +238,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN
|
||||
|
||||
// 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
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
+31
-1
@@ -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
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
+60
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
+34
-1
@@ -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
|
||||
@@ -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({
|
||||
|
||||
+34
@@ -501,3 +501,37 @@ describe('highlight ==mark==', () => {
|
||||
expect(markPresent('a == b == c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autolink / bare-URL preservation', () => {
|
||||
it('keeps a bare URL bare instead of rewriting it to [url](url)', () => {
|
||||
expect(roundTrip('visit https://sim.ai today').trim()).toBe('visit https://sim.ai today')
|
||||
expect(roundTrip('both https://a.com and https://b.com').trim()).toBe(
|
||||
'both https://a.com and https://b.com'
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses an angle autolink and a bare email to their bare form', () => {
|
||||
expect(roundTrip('see <https://sim.ai> here').trim()).toBe('see https://sim.ai here')
|
||||
expect(roundTrip('mail <a@b.com> now').trim()).toBe('mail a@b.com now')
|
||||
})
|
||||
|
||||
it('preserves explicit and titled links (only bare autolinks collapse)', () => {
|
||||
expect(roundTrip('[Sim](https://sim.ai)').trim()).toBe('[Sim](https://sim.ai)')
|
||||
expect(roundTrip('[https://a.com](https://b.com)').trim()).toBe(
|
||||
'[https://a.com](https://b.com)'
|
||||
)
|
||||
expect(roundTrip('[text](https://x.com "t")').trim()).toBe('[text](https://x.com "t")')
|
||||
})
|
||||
|
||||
it('never collapses a URL that only appears inside code', () => {
|
||||
expect(roundTrip('```\nvisit https://sim.ai\n```').trim()).toBe(
|
||||
'```\nvisit https://sim.ai\n```'
|
||||
)
|
||||
expect(roundTrip('inline `https://sim.ai` code').trim()).toBe('inline `https://sim.ai` code')
|
||||
})
|
||||
|
||||
it('round-trips a bare URL idempotently', () => {
|
||||
const once = roundTrip('go to https://sim.ai now')
|
||||
expect(roundTrip(once)).toBe(once)
|
||||
})
|
||||
})
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
|
||||
/**
|
||||
* Reactively tracks `editor.isEditable` for React node views.
|
||||
*
|
||||
* The editor runs with `shouldRerenderOnTransaction: false`, and `editor.setEditable()` updates the
|
||||
* option + re-applies view state but does NOT change any node-view prop — so a node view that reads
|
||||
* `editor.isEditable` once at render keeps a stale value after the editability toggles (e.g. an agent
|
||||
* stream settling into the doc). That leaves images showing no drag/resize/selection affordances and
|
||||
* code blocks stuck on their read-only label until the node happens to re-render. Subscribing to the
|
||||
* editor's `transaction`/`update` events re-reads the flag so the node view stays in sync.
|
||||
*/
|
||||
export function useEditorEditable(editor: Editor): boolean {
|
||||
const [editable, setEditable] = useState(editor.isEditable)
|
||||
useEffect(() => {
|
||||
const sync = () => setEditable(editor.isEditable)
|
||||
sync()
|
||||
editor.on('transaction', sync)
|
||||
editor.on('update', sync)
|
||||
return () => {
|
||||
editor.off('transaction', sync)
|
||||
editor.off('update', sync)
|
||||
}
|
||||
}, [editor])
|
||||
return editable
|
||||
}
|
||||
Reference in New Issue
Block a user