Skip to the content

Capsid menu

app/components/editor/writing-surface.tsx

130 lines and 8 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of writing-surface.tsx
FunctionLinesComplexityCoveredCRAPMutation
insertIntoMarkdown24 to 3350%WatchCRAP 30No data: mutation testing has not run on this repo yet
WritingSurface35 to 13790%CriticalCRAP 90No data: mutation testing has not run on this repo yet
<anonymous:58>58 to 6430%WatchCRAP 12No data: mutation testing has not run on this repo yet
choose65 to 7220%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:79>79 to 8310%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:85>85 to 8520%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 10120%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:116>116 to 11810%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/editor/rich-editor.tsx, app/components/media/library-insert.tsx, app/components/media/upload.ts, app/lib/editor/markdown.mjs, app/lib/site-markdown.ts. Imported by app/routes/book.file.tsx, app/routes/editor.tsx.

Source

// Carrel's writing surface: the formatted editor by default (ruling point 3), with
// the Markdown kept byte for byte underneath, and "Markdown" beside it, which is Capsomer's Markdown
// editor exactly as before. Both get Carrel's blocks, its link targets and its image upload, set in
// the serif prose layout, with "Insert from library" beside them. The choice is remembered on this
// device. The formatted editor is components/editor/rich-editor.tsx; the Markdown one is Capsomer's.

import { EditorView } from "@codemirror/view";
import { useEffect, useRef, useState } from "react";
import { MarkdownEditor, type LinkTarget } from "capsomer/react/markdown-editor";
import { Segmented } from "capsomer/react/segmented";

import { RichEditor, type RichEditorHandle } from "~/components/editor/rich-editor";
import { LibraryInsert } from "~/components/media/library-insert";
import { uploadImage, type EditorMedia } from "~/components/media/upload";
import type { Dialect } from "~/lib/editor/markdown.mjs";
import { SCAFFOLDS } from "~/lib/site-markdown";

export type { LinkTarget };

type Mode = "formatted" | "markdown";
const MODE_KEY = "carrel:editor-mode";

/** In the Markdown editor: a block at the cursor on a line of its own, as the editor's own blocks are put. */
function insertIntoMarkdown(root: HTMLElement | null, text: string) {
  const dom = root?.querySelector<HTMLElement>(".cm-editor");
  const view = dom ? EditorView.findFromDOM(dom) : null;
  if (!view) return;
  const { from, to } = view.state.selection.main;
  const line = view.state.doc.lineAt(from);
  const prefix = line.from === from && line.text.trim() === "" ? "" : "\n\n";
  view.dispatch({ changes: { from, to, insert: `${prefix}${text}` }, selection: { anchor: from + prefix.length + text.length }, scrollIntoView: true });
  view.focus();
}

export function WritingSurface({
  label,
  value,
  onChange,
  readOnly,
  linkTargets,
  media,
  dialect = "site",
}: {
  /** What the surface is called to a screen reader, for example "Post" or "Scene". */
  label: string;
  value: string;
  onChange: (next: string) => void;
  readOnly: boolean;
  linkTargets: LinkTarget[];
  /** The project's media endpoint and the site's figure markup. Absent: no image upload or insert. */
  media?: EditorMedia | null;
  /** A site's Markdown (directives, math) or a book's. */
  dialect?: Dialect;
}) {
  const root = useRef<HTMLDivElement>(null);
  const rich = useRef<RichEditorHandle | null>(null);
  const [mode, setMode] = useState<Mode>("formatted");
  useEffect(() => {
    try {
      if (localStorage.getItem(MODE_KEY) === "markdown") setMode("markdown");
    } catch {
      // Storage refused: the formatted editor, as the default.
    }
  }, []);
  const choose = (next: Mode) => {
    setMode(next);
    try {
      localStorage.setItem(MODE_KEY, next);
    } catch {
      // Not remembered, still switched.
    }
  };
  // Never for a Reader, whatever the parent passes: a read-only editor inserts nothing.
  const images = readOnly ? null : (media ?? null);
  // The editor shows the picture it just uploaded at `src` (an address Carrel's page can load), while
  // the Markdown must carry the site's own address, so each upload remembers which is which.
  const sites = useRef(new Map<string, string>());
  const onUpload = images
    ? async (file: File) => {
        const uploaded = await uploadImage(images.endpoint, file);
        sites.current.set(uploaded.src, uploaded.url);
        return { url: uploaded.src };
      }
    : undefined;
  const imageMarkdown = images ? ({ url, alt }: { url: string; alt: string }) => images.figure(sites.current.get(url) ?? url, alt) : undefined;

  return (
    <div className="app-writing" ref={root} data-mode={mode}>
      <div className="app-writing-tools">
        <Segmented
          legend={`Edit ${label.toLowerCase()} as`}
          hideLegend
          size="sm"
          value={mode}
          onChange={choose}
          options={[
            { value: "formatted", label: "Formatted" },
            { value: "markdown", label: "Markdown" },
          ]}
        />
        {images ? <LibraryInsert media={images} insert={(md) => (mode === "formatted" ? rich.current?.insertMarkdown(md) : insertIntoMarkdown(root.current, md))} /> : null}
      </div>
      {mode === "formatted" ? (
        <RichEditor
          ariaLabel={readOnly ? `${label}, formatted, read only` : `${label}, formatted`}
          value={value}
          onChange={onChange}
          readOnly={readOnly}
          dialect={dialect}
          placeholder="Write here."
          scaffolds={SCAFFOLDS}
          linkTargets={linkTargets}
          onUpload={onUpload}
          imageMarkdown={imageMarkdown}
          accept={images?.accept}
          onReady={(handle) => {
            rich.current = handle;
          }}
        />
      ) : (
        <MarkdownEditor
          label={<span className="cap-sr-only">{label}</span>}
          ariaLabel={readOnly ? `${label}, markdown, read only` : `${label}, markdown`}
          value={value}
          onChange={onChange}
          readOnly={readOnly}
          placeholder="Write in markdown."
          scaffolds={SCAFFOLDS}
          linkTargets={linkTargets}
          accept={images?.accept}
          onUpload={onUpload}
          imageMarkdown={imageMarkdown}
        />
      )}
    </div>
  );
}