/* Copyright 2026 Marimo. All rights reserved. */ import { EditorSelection, type SelectionRange, Text, Transaction, } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { toast } from "@/components/ui/use-toast"; import { getRequestClient } from "@/core/network/requests"; import { filenameAtom } from "@/core/saving/file-state"; import { store } from "@/core/state/jotai"; import { type FilePath, Paths } from "@/utils/paths"; const MAX_BASE64_SIZE_KB = 100; function hasSelection(view: EditorView) { return !view.state.selection.main.empty; } function toggleAllLines({ view, selection, markup, }: { view: EditorView; selection: SelectionRange; markup: string; }) { const changes = []; let allLinesHaveMarkup = true; for (let pos = selection.from; pos <= selection.to; pos++) { const line = view.state.doc.lineAt(pos); const lineText = line.text; if (!lineText.startsWith(markup)) { allLinesHaveMarkup = false; break; } } for (let pos = selection.from; pos <= selection.to; ) { const line = view.state.doc.lineAt(pos); const lineText = line.text; const hasMarkup = lineText.startsWith(markup); if (allLinesHaveMarkup) { changes.push({ from: line.from, to: line.from + markup.length, insert: Text.of([""]), }); } else { if (hasMarkup) { changes.push({ from: line.from, to: line.from + markup.length, insert: Text.of([""]), }); } changes.push({ from: line.from, insert: Text.of([markup]), }); } pos = line.to + 1; // Move to the start of the next line } return changes; } function wrapWithMarkup({ view, range, markupAfter, markupBefore, }: { view: EditorView; range: SelectionRange; markupBefore: string; markupAfter: string; }) { if (range.empty) { const wordRange = view.state.wordAt(range.head); if (wordRange) { range = wordRange; } } const isMarkupBefore = view.state.sliceDoc(range.from - markupBefore.length, range.from) === markupBefore; const isMarkupAfter = view.state.sliceDoc(range.to, range.to + markupAfter.length) === markupAfter; const changes = []; changes.push( isMarkupBefore ? { from: range.from - markupBefore.length, to: range.from, insert: Text.of([""]), } : { from: range.from, insert: Text.of(markupBefore.split("\n")), }, isMarkupAfter ? { from: range.to, to: range.to + markupAfter.length, insert: Text.of([""]), } : { from: range.to, insert: Text.of(markupAfter.split("\n")), }, ); const extendBefore = isMarkupBefore ? -markupBefore.length : markupBefore.length; const extendAfter = isMarkupAfter ? -markupAfter.length : markupAfter.length; return { changes, range: EditorSelection.range( range.from + extendBefore, range.to + extendAfter, ), }; } export function insertBlockquote(view: EditorView) { // Only apply on selection if (!hasSelection(view)) { return false; } const markup = "> "; const changes = toggleAllLines({ view, selection: view.state.selection.main, markup, }); if (changes.length > 0) { view.dispatch( view.state.update({ changes, scrollIntoView: true, userEvent: "input" }), ); } view.focus(); return true; } export function insertBoldMarker(view: EditorView) { // Only apply on selection if (!hasSelection(view)) { return false; } const changes = view.state.changeByRange((range) => { if (range.empty) { const wordRange = view.state.wordAt(range.head); if (wordRange) { range = wordRange; } } return wrapWithMarkup({ view, range, markupBefore: "**", markupAfter: "**", }); }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); view.focus(); return true; } export function insertCodeMarker(view: EditorView) { // Only apply on selection if (!hasSelection(view)) { return false; } const changes = view.state.changeByRange((range) => { if (range.empty) { const wordRange = view.state.wordAt(range.head); if (wordRange) { range = wordRange; } } const lineFrom = view.state.doc.lineAt(range.from).number; const lineTo = view.state.doc.lineAt(range.to).number; const isMultiline = lineFrom !== lineTo; const fenceBefore = isMultiline ? "```\n" : "`"; const fenceAfter = isMultiline ? "\n```" : "`"; return wrapWithMarkup({ view, range, markupBefore: fenceBefore, markupAfter: fenceAfter, }); }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); view.focus(); return true; } export function insertItalicMarker(view: EditorView) { // Apply with or without selection const changes = view.state.changeByRange((range) => { if (range.empty) { const wordRange = view.state.wordAt(range.head); if (wordRange) { range = wordRange; } } return wrapWithMarkup({ view, range, markupBefore: "_", markupAfter: "_" }); }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); view.focus(); return true; } export function insertLink(view: EditorView, url = "http://") { // Only apply on selection if (!hasSelection(view)) { return false; } const changes = view.state.changeByRange((range) => { const text = view.state.sliceDoc(range.from, range.to); return { changes: [ { from: range.from, to: range.to, insert: `[${text}](${url})` }, ], range, }; }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); const { to } = changes.selection.main; // Can fail in tests try { view.dispatch({ selection: EditorSelection.create([ EditorSelection.range(to + 3, to + 3 + url.length), EditorSelection.cursor(to + 3 + url.length), ]), }); } catch { // Do nothing } view.focus(); return true; } export async function insertImage(view: EditorView, file: File) { const reader = new FileReader(); const dataUrl = await new Promise((resolve) => { reader.onload = () => resolve(reader.result as string); reader.readAsDataURL(file); }); let savedFilePath: string | undefined; // If the file is base64 encoded, we can save it locally to prevent large file strings try { if (dataUrl.startsWith("data:")) { const base64 = dataUrl.split(",")[1]; let inputFilename = prompt( "We can save your image as a file. Enter a filename.", file.name, ); const extension = file.type.split("/")[1]; // A cancelled prompt returns null if (inputFilename === null) { // Large strings do not make sense to write/save to the document const base64SizeKB = Math.round(dataUrl.length / 1024); if (base64SizeKB > MAX_BASE64_SIZE_KB) { toast({ title: "Content too large", description: `The content size is ${base64SizeKB} KB, which is too large to paste directly into the document.`, }); return; } } else { if (inputFilename.trim() === "") { inputFilename = file.name; } else if (!inputFilename.endsWith(`.${extension}`)) { inputFilename = `${inputFilename}.${extension}`; } const filepath = store.get(filenameAtom); const notebookDir = filepath ? Paths.dirname(filepath) : null; const publicFolderPath = notebookDir ? `${notebookDir}/public` : "public"; const createFileRes = await getRequestClient().sendCreateFileOrFolder({ path: publicFolderPath as FilePath, type: "file", name: inputFilename, contents: base64, }); if (createFileRes.success) { savedFilePath = createFileRes.info?.path; // We want the relative path to the public folder if ( savedFilePath && notebookDir && savedFilePath.startsWith(notebookDir) ) { savedFilePath = Paths.rest(savedFilePath, notebookDir); } toast({ title: "Image uploaded successfully", description: `We've uploaded your image at ${savedFilePath}`, }); } else { toast({ title: "Failed to upload image. Using raw base64 string.", }); } } } } catch { toast({ title: "Failed to upload image. Using raw base64 string.", }); } const changes = view.state.changeByRange((range) => { let text = view.state.sliceDoc(range.from, range.to); if (text.trim() === "") { text = "alt"; } return { changes: [ { from: range.from, to: range.to, insert: `![${text}](${savedFilePath ?? dataUrl})`, }, ], range, }; }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); const { to } = changes.selection.main; try { view.dispatch({ selection: EditorSelection.create([ EditorSelection.range(to + 4, to + 4 + dataUrl.length), EditorSelection.cursor(to + 4 + dataUrl.length), ]), }); } catch { // Do nothing } view.focus(); return true; } export async function insertTextFile(view: EditorView, file: File) { const text = await file.text(); // Just insert at the cursor const changes = view.state.changeByRange((range) => { return { // Insert at the start of the range, don't replace any existing text changes: [{ from: range.from, to: range.from, insert: text }], range, }; }); view.dispatch( view.state.update(changes, { scrollIntoView: true, annotations: Transaction.userEvent.of("input"), }), ); view.focus(); return true; } export function insertUL(view: EditorView) { // Only apply on selection if (!hasSelection(view)) { return false; } const { changes } = view.state.changeByRange((range) => { const markupOne = "- "; const markupTwo = "* "; const rangeText = view.state .sliceDoc(range.from, range.to + markupTwo.length) .startsWith(markupTwo) ? markupTwo : markupOne; return { range, changes: toggleAllLines({ view, selection: range, markup: rangeText }), }; }); if (changes.length > 0) { view.dispatch( view.state.update({ changes, scrollIntoView: true, userEvent: "input" }), ); } view.focus(); return true; } export function insertOL(view: EditorView) { // Only apply on selection if (!hasSelection(view)) { return false; } const { changes } = view.state.changeByRange((range) => { const markup = "1. "; return { range, changes: toggleAllLines({ view, selection: range, markup }), }; }); if (changes.length > 0) { view.dispatch( view.state.update({ changes, scrollIntoView: true, userEvent: "input" }), ); } view.focus(); return true; }