Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
4.2 kB · 106 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107// src/editor/extensions/url-paste-handler.ts/** * CM6 paste handler that wraps a selection as `[selected](url)` when * the clipboard's text payload is a URL. Empty selection ⇒ insert raw * URL (the inline-link-decoration render layer makes it clickable). * * Trigger conditions: * - editor is not read-only * - selection is single (not multi-cursor) * - clipboard text is a single URL matching the tightened regex below * * Conditions that delegate to CM6 default (return false; no preventDefault): * - read-only * - multi-cursor * - clipboard text contains spaces or extra content (text contains a * URL but isn't ONLY a URL) * - non-URL text * - image clipboard (handled earlier by imagePasteHandler — registered * before this handler in CM6Editor.tsx; this code is never reached * for image pastes because imagePasteHandler.preventDefault stops * propagation) * * Clipboard text source: * - read `text/plain` first, falling back to `text/uri-list` (CM6's own * fallback; matches drag-and-drop URL semantics on some platforms) * * Whole-string anchor (^…$): we wrap only when the user's intent is * "this clipboard IS a URL". A clipboard that contains `see https://x` * is plain text and pastes verbatim; the render layer still styles the * URL portion clickable. * * Regex parts (ALL host-style URLs require a LITERAL dot — `\.`): * - https?:// + host-with-dot pattern (`[^\s/$?#]+\.[^\s]+`): rejects * `https://localhost`, accepts `https://x.y`, `https://example.com/path?q=1` * - mailto:local@host.tld — must contain @ AND a literal dot in host * - tel:digits — must contain at least one digit anywhere after `tel:` * * Why escaped dot: a bare `.` matches any character in regex, so the * naive `[^\s/$.?#].[^\s]*` pattern would let `https://localhost` slip * through (the `.` would match `t` or any other host char). The literal * `\.` forces an actual dot, which is the correct host-with-tld guard. */import { EditorView } from '@codemirror/view'import { Transaction, EditorSelection } from '@codemirror/state'import type { Extension } from '@codemirror/state'
// CRITICAL: the dot in the host pattern is ESCAPED (`\.`) to enforce a// LITERAL dot. A bare `.` would match any char and let `https://localhost`// slip through. mailto: also requires a literal dot in the host.const URL_RE = /^(?:https?:\/\/[^\s/$?#]+\.[^\s]+|mailto:[^\s@]+@[^\s]+\.[^\s]+|tel:[^\s]*\d[^\s]*)$/
function readClipboardUrl(dt: DataTransfer): string | null { // text/plain first (most common) const plain = dt.getData('text/plain').trim() if (plain && URL_RE.test(plain)) return plain // text/uri-list fallback. RFC 2483 allows `#`-prefixed comment lines; // strip them, take the first non-comment URL. const uriList = dt.getData('text/uri-list').trim() if (uriList) { const firstUrl = uriList .split(/\r?\n/) .map((s) => s.trim()) .find((s) => s.length > 0 && !s.startsWith('#')) if (firstUrl && URL_RE.test(firstUrl)) return firstUrl } return null}
export function urlPasteHandler(): Extension { return EditorView.domEventHandlers({ paste(event, view) { // Allow workspace event listeners to intercept before our handler if (event.defaultPrevented) return false if (view.state.readOnly) return false const dt = event.clipboardData if (!dt) return false
const url = readClipboardUrl(dt) if (!url) return false
const sel = view.state.selection if (sel.ranges.length !== 1) return false // multi-cursor: delegate
const range = sel.main const from = range.from const to = range.to const selected = view.state.sliceDoc(from, to)
const insert = selected.length > 0 ? `[${selected}](${url})` : url
const cursorPos = from + insert.length
view.dispatch({ changes: { from, to, insert }, selection: EditorSelection.single(cursorPos), scrollIntoView: true, annotations: Transaction.userEvent.of('input.paste'), })
event.preventDefault() return true }, })}