a rich text editor whose document reads as markdown, as a page or a form field
npx shadcn add @ag/editorAlready installed? Preview an update
npx shadcn add @ag/editor --dry-runYour installed source stays unchanged until you update it. How updates work
- Fixed: the plain
fieldeditor's tools no longer show through a layer laid over the page (a full view, a dialog). Wordgard ranks its own panels at z-index 300, and nothing in the plain field kept that number inside the editor; the well is now the stacking context for everything Wordgard draws, in every face. - Changed (breaking): the editor looks like
markdown.variant="page", the default, has no box: the document at the size of where it is placed (no pinned 15 px), set as markdown sets it, its first block flush with the top, its measure yours (className, as markdown's is); the tools are a quiet row in the registry's voice (the alpha ladder's quiet tier, a wash under the hand, a wash when on, never a solid fill, no grey bar, a hairline under it) that stays at the top while a long document scrolls (sticky in its scroll container, the caret scrolled clear of it); focus is the tokens' keyboard ring round the document. Migration: an editor in a form (a comment, a message, a proposal's body) takesvariant="field"to keep a bounded box. - New:
variant="field", a field in a form: the textarea's well (its surface and hairline, brightening under the hand and with the keyboard's focus) round the tools and the document, at the textarea's size (13 px, its padding), in both materials (material,scheme, from aMaterialProviderround it): in glass a well of the one glass material, its rim lit by the hand and the keyboard.invalidstains it red and setsaria-invalidon the document. - New:
rows, the lines it is tall when empty (5 on a page, 3 in a field). Migration:--editor-min-heightis gone; setrows. - Changed: Wordgard's menus (a block's kinds, a table's verbs) are menu's plain popup with its rows, its dialogs and the link's address on the registry's washes and hairlines; the / menu is menu's popup and rows, glass in a glass editor. A quote is markdown's (a wash, the quote voice, the mark in the gutter), where Wordgard's grey bar and indent showed through.
- Changed: the uploads say what they are by name. The list is
pictures going up(it wasImage uploads), each bar<file> going up, its retrytry <file> againand its cancelcancel <file>(they wereretryandcancel, the same for every file). Migration: a test or a script that finds them by name uses the new names; a bar is aprogressbarrole, not a<progress>element.
the demo above, verbatim
"use client"
import "@/registry/base-nova/ui/edge-fade"
import { useState } from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Editor } from "./editor"
// #region essay
// Long enough to scroll past its tools: they stay at the top of the page.
const ESSAY = `# The slow road
We left before the sun was up, and the road ran along the water the whole way. **One lens, no gimbal**, and a rule: *stop when the light is good*, never when the map says so.
## What we carried
- a 35mm prime, and nothing wider
- two batteries, both charged the night before
- the wall, the roof of the car, a friend's shoulder
> The best camera is the one that's with you.
## The bay
The first stop was the bay, an hour after we set out. The tide was out and the boats sat on the sand at angles, the light coming low across them from the east. We stayed longer than we meant to.
### The pass
By noon the road climbed away from the water into the pass. The light went flat and hard, and we drove through it without stopping, which was the rule working.
## Coming back
The light came back on the way down, in the last hour. The pass at dusk was the picture of the day, and it was the one we nearly did not stop for.
`
// #endregion
// #region proposal
// A form: a title, the body in a field, a send. The body is wrong past 280
// characters, and says so in the field's red.
function Proposal() {
const [body, setBody] = useState("")
return (
<form
className="flex flex-col gap-3"
onSubmit={(event) => event.preventDefault()}
>
<Input placeholder="what to change" aria-label="title" />
<Editor
variant="field"
format="markdown"
label="why"
placeholder="why, and what it would take"
images={false}
onChange={setBody}
invalid={body.length > 280}
/>
<Button type="submit" className="self-end">
propose
</Button>
</form>
)
}
// #endregion
// #region note
// A markdown file as it comes off a disk: the editor holds what it can as itself and
// keeps the rest (the front matter, the task list, the aligned table) as its source.
const NOTE = `---
title: trip notes
---
# trip notes
Leave at **7**, coffee on the way, the *beach* at golden hour.
- one lens, no gimbal
- ask about the boat
- Saturday, if the wind drops
\`\`\`sh
ffmpeg -i bay.mov -vf fps=3 frames/%03d.jpg
\`\`\`
| shot | lens |
|------|------|
| the bay | 24mm |
| the pass | 35mm |
- [ ] charge the batteries
| right | aligned |
|------:|--------:|
| 1 | 2 |
`
// #endregion
function MarkdownFile() {
const [text, setText] = useState(NOTE)
return (
<div className="grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_18rem]">
<Editor
label="trip notes.md"
format="markdown"
defaultValue={NOTE}
onChange={setText}
/>
{/* The surface a frame round the scroller, whose cut edges fade. */}
<div className="flex max-h-[32rem] rounded-lg bg-foreground/4">
<pre className="ag-edge-fade-xy min-h-0 flex-1 p-3 font-mono text-[11px] leading-5 text-foreground/70">
{text}
</pre>
</div>
</div>
)
}
export default function Demo() {
const [html, setHtml] = useState("")
return (
<div className="space-y-8">
<Sample
name="page"
title="a page to write in"
description="the default: no box, the document at the size of where it is placed, set as markdown sets it; the measure is yours (className), as markdown's is."
with="essay"
>
<Editor
label="the slow road"
format="markdown"
defaultValue={ESSAY}
className="mx-auto max-w-[42rem]"
/>
</Sample>
<Sample
name="field"
title="a field in a form"
description="variant field: the textarea's well and size round the tools and the document. MaterialProvider turns it to glass. Red past 280 characters (invalid)."
with="proposal"
>
<div className="grid gap-4 md:grid-cols-2">
<Proposal />
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
<Proposal />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="editor"
glance="large"
title="HTML in/out"
description="supply upload for image paste, drop and attachments"
>
<Editor
label="Example document"
defaultValue="<h2>A place to think</h2><p>Write, select text, or make a list.</p>"
onChange={setHtml}
/>
<details className="mt-4">
<summary>document HTML</summary>
<pre className="ag-edge-fade-xy text-xs">{html}</pre>
</details>
</Sample>
<Sample
name="markdown"
title="a markdown file"
description="format markdown reads and writes the file itself: code blocks, rules, struck text and tables join the editor, and what it cannot hold (front matter, a task list, an aligned table) stays as its source, written back untouched. Type markdown (- , # , **bold**) and it becomes what it means; / in an empty line offers the blocks; a web address links itself at the space after it; Tab and ⇧Tab nest list items; ⌘⌥1 to 6 make headings, ⌘⇧7 and ⌘⇧8 lists, ⌘E code, ⌘⇧X struck; paste a link over chosen words to link them. The text on the right is what would be saved; it changes only once you edit."
with="note"
>
<MarkdownFile />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { Image as ImageIcon, RotateCw, X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import type { SlashState } from "@/registry/base-nova/ui/editor-slash"
import { MARKDOWN, MARKDOWN_VARS } from "@/registry/base-nova/ui/markdown"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
type WornScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Hints } from "@/registry/base-nova/ui/menu"
import type { MediaUploader } from "@/registry/base-nova/ui/upload"
import type { EditorFormat, EditorJob } from "./editor-engine"
import "./editor.css"
// A RICH TEXT EDITOR whose document wears markdown's look, so writing reads as reading.
// Two variants. `page` (the default, for writing): no box, the document at the size
// of where it is placed, a quiet row of tools above it that stays at the top while a
// long document scrolls. `field` (for a form: a comment, a proposal's body): the
// textarea's well round the tools and the document, at the textarea's size, in either
// material. Wordgard draws the tools and the document; the editor draws the row the
// tools sit in, the well, and the ring (editor.css restyles Wordgard's own nodes).
/** `page`: the document as it will be read. `field`: a bounded field in a form. */
export type EditorVariant = "page" | "field"
/** THE FACES, with SLASH below the one place the material is read: each variant's
* well round the tools and the document, the row the tools sit in and the document's
* own node, in each material. Glass is the material's (`glassVariants`) with the
* textarea's states; plain is the registry's surfaces at the alpha ladder. A glass
* page has no well either: its tools are a bar of glass over what scrolls. Focus
* answers the keyboard only (@ag/tokens): Wordgard's editable node (`wg-content`)
* takes it, and the editor draws it, the page's document as the ring, the field's
* well as its own state. Wrong (`invalid`), the well is stained red. */
const FACES = {
page: {
plain: {
well: "",
tools:
"sticky top-0 z-10 mb-4 border-foreground/8 border-b bg-background/90 py-1 text-foreground backdrop-blur-md",
document:
"rounded-md has-[wg-content:focus-visible:not([data-focus-pointer])]:focus-ring",
},
glass: {
well: "",
// Glass sets `relative`: the row's own placement comes after it.
tools: cn(
glassVariants({ shape: "surface" }),
"sticky top-2 z-10 mb-4 w-fit max-w-full p-1 [--glass-radius:--theme(--radius-2xl)]",
),
document:
"rounded-md has-[wg-content:focus-visible:not([data-focus-pointer])]:focus-ring",
},
},
// A menu Wordgard opens hangs below the well: while one is open the well stands
// above whatever follows it.
field: {
plain: {
well: "relative rounded-md has-[wg-menu-list:not([style*=none])]:z-20 bg-foreground/[0.04] ring-1 ring-foreground/12 ring-inset transition-[box-shadow,background-color] hover:ring-foreground/22 has-[wg-content:focus-visible:not([data-focus-pointer])]:bg-foreground/[0.06] has-[wg-content:focus-visible:not([data-focus-pointer])]:ring-foreground/40 data-invalid:ring-tone-bad/70",
tools: "border-foreground/8 border-b p-1 text-foreground",
document: "",
},
glass: {
well: cn(
glassVariants({ shape: "surface" }),
// textarea's states, `!` as the material's state classes are, so they win
// over a light given inline; its transition is the glass's own.
"[--glass-radius:--theme(--radius-xl)] has-[wg-menu-list:not([style*=none])]:z-20 hover:[--glass-rim:1.3]! has-[wg-content:focus-visible:not([data-focus-pointer])]:[--glass-rim:1.7]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!",
),
tools: "border-current/10 border-b p-1",
document: "",
},
},
} as const
/** The / menu's popup and its rows in each material: menu's own faces. */
const SLASH = {
plain: {
popup:
"rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
row: "rounded-md aria-selected:bg-foreground/8",
},
glass: {
popup: cn(
glassVariants({ shape: "surface" }),
"[--glass-radius:14px] [--glass-blur:14px]",
),
row: "rounded-[10px] aria-selected:bg-current/12",
},
} as const
/** The type round the document and its padding (px) on Wordgard's editable node, and
* how many lines tall it is when empty: the page inherits the size of where it is
* placed and sits flush with the page's text; the field is the textarea's (its size,
* its padding, three rows). */
const DOCUMENT = {
page: { size: "", padX: 0, padY: 0, rows: 5 },
field: { size: "text-[13px]", padX: 10, padY: 6, rows: 3 },
} as const
export type EditorProps = {
/** The initial document, in `format`. The editor is uncontrolled; use key to load
* another document. */
defaultValue?: string
/** "html" (the default) for a body a site renders; "markdown" for a markdown
* file, read and written as markdown, with code blocks, rules, struck text and
* tables, and anything it cannot hold kept as its source (editor-markdown). */
format?: EditorFormat
/** The document in `format`, after every change. */
onChange?: (value: string) => void
placeholder?: string
label: string
className?: string
/** Disable images for formats that cannot represent them, e.g. Telegram messages. */
images?: boolean
upload?: MediaUploader
/** "page" (the default): no box, the document at the size of where it is placed,
* its tools in a quiet row that stays at the top while it scrolls; its measure is
* the consumer's (`className`), as markdown's is. "field": a field in a form, the
* textarea's well and size. */
variant?: EditorVariant
/** Lines tall at least, empty: 5 on a page, 3 in a field (the textarea's). */
rows?: number
/** What is written is wrong: the field's well stained red, and `aria-invalid` on
* the document. */
invalid?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}
export function Editor({
format = "html",
defaultValue = format === "html" ? "<p></p>" : "",
onChange,
placeholder,
label,
className,
images = true,
upload,
variant = "page",
rows,
invalid = false,
material,
scheme,
}: EditorProps) {
const m = useMaterial(material)
// Over a picture it is read by the part of it under the editor.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
const face = FACES[variant][m]
const doc = DOCUMENT[variant]
const host = useRef<HTMLDivElement>(null)
const toolbar = useRef<HTMLDivElement>(null)
const wrong = useRef(invalid)
wrong.current = invalid
const callbacks = useRef({ onChange, upload })
callbacks.current = { onChange, upload }
const [jobs, setJobs] = useState<EditorJob[]>([])
const pendingJobs = useRef<EditorJob[]>([])
const [error, setError] = useState("")
const [submitBlocked, setSubmitBlocked] = useState(false)
const [slash, setSlash] = useState<SlashState | null>(null)
// Initial document/schema are intentionally read once. Changing the key mounts another document.
// biome-ignore lint/correctness/useExhaustiveDependencies: uncontrolled editor
useEffect(() => {
let cancelled = false
let teardown: (() => void) | undefined
import("./editor-engine")
.then(({ mountEditor }) => {
if (cancelled || !host.current || !toolbar.current) return
teardown = mountEditor(host.current, {
toolbar: toolbar.current,
documentClass: MARKDOWN,
defaultValue,
format,
placeholder,
label,
images,
// Read once, like the document and the schema: whether the editor offers
// images at all is a mount-time decision.
upload: callbacks.current.upload,
onChange: (value) => callbacks.current.onChange?.(value),
onJobs: (next) => {
pendingJobs.current = next
setJobs(next)
},
onSlash: setSlash,
})
markInvalid(host.current, wrong.current)
})
.catch((e) => {
if (!cancelled) setError(String(e))
})
return () => {
cancelled = true
teardown?.()
}
}, [])
useEffect(() => markInvalid(host.current, invalid), [invalid])
useEffect(() => {
const form = host.current?.closest("form")
const prevent = (event: Event) => {
if (pendingJobs.current.length) {
event.preventDefault()
event.stopPropagation()
setSubmitBlocked(true)
}
}
form?.addEventListener("submit", prevent, true)
return () => form?.removeEventListener("submit", prevent, true)
}, [])
const lines = rows ?? doc.rows
return (
<div
ref={here}
data-slot="editor"
data-variant={variant}
data-material={m}
className={cn("not-prose space-y-2", schemeOf(s), className)}
>
{/* Wordgard ranks its own layers (its panels at z-index 300): the well is
their stacking context, so none of them outranks a layer of the page (a
full view, a dialog) the editor sits under. */}
<div
data-slot="editor-well"
data-invalid={invalid || undefined}
className={cn("isolate", doc.size, face.well)}
>
<div
data-slot="editor-toolbar"
className={cn("text-[13px]", face.tools)}
>
{/* Wordgard places its toolbar here and adds its own classes to this node:
React gives it none, so it never takes them back. */}
<div ref={toolbar} data-slot="editor-panels" />
</div>
<div
ref={host}
data-slot="editor-document"
className={face.document}
style={
{
...MARKDOWN_VARS,
"--ag-editor-pad": `${doc.padY}px ${doc.padX}px`,
"--ag-editor-min": `calc(${lines}lh + ${2 * doc.padY}px)`,
} as React.CSSProperties
}
/>
</div>
{slash && <SlashMenu state={slash} material={m} scheme={s} />}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{submitBlocked && jobs.length > 0 && (
<p role="alert" className="text-[13px] text-tone-bad">
the pictures still going up have to finish, or be cancelled, before
this is published
</p>
)}
{jobs.length > 0 && <Uploads jobs={jobs} />}
</div>
)
}
/** `aria-invalid` on the document itself (Wordgard's editable node, the textbox a
* screen reader is on), once it is mounted. */
function markInvalid(host: HTMLElement | null, invalid: boolean) {
const textbox = host?.querySelector("[role=textbox]")
if (invalid) textbox?.setAttribute("aria-invalid", "true")
else textbox?.removeAttribute("aria-invalid")
}
/** The pictures going up: each one small as it was dropped, a hairline that fills as its
* bytes go, then "preparing" while the server makes its renditions, the reason when it
* failed, retry and cancel at hand. */
function Uploads({ jobs }: { jobs: readonly EditorJob[] }) {
return (
<ul
aria-label="pictures going up"
data-slot="editor-uploads"
className="flex flex-col gap-1.5"
>
{jobs.map((job) => {
const percent = Math.round(job.progress * 100)
const state = job.error
? job.error
: job.progress >= 1
? "preparing"
: `${percent}%`
return (
<li
key={job.id}
data-failed={job.error ? "" : undefined}
className="group/up flex items-center gap-3 rounded-lg bg-foreground/4 p-1.5 pr-1 text-[13px]"
>
{job.preview ? (
// biome-ignore lint/performance/noImgElement: the dropped file itself, an object URL no loader can resize
<img
src={job.preview}
alt=""
className="size-9 shrink-0 rounded-md object-cover group-data-failed/up:opacity-50"
/>
) : (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-foreground/6 text-foreground/45">
<ImageIcon className="size-4" />
</span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-baseline gap-2">
<span className="truncate text-foreground/85">{job.name}</span>
<span
role="status"
className="shrink-0 text-[12px] text-foreground/45 tabular-nums group-data-failed/up:text-tone-bad"
>
{state}
</span>
</span>
{!job.error && (
<span
role="progressbar"
aria-label={`${job.name} going up`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="h-0.5 overflow-hidden rounded-full bg-foreground/10"
>
<span
className="block h-full w-(--ag-upload-done) rounded-full bg-foreground/60 transition-[width] duration-200"
style={
{
"--ag-upload-done": `${percent}%`,
} as React.CSSProperties
}
/>
</span>
)}
</span>
{job.error && (
<button
type="button"
onClick={job.retry}
aria-label={`try ${job.name} again`}
className={UPLOAD_VERB}
>
<RotateCw className="size-3.5" />
</button>
)}
<button
type="button"
onClick={job.cancel}
aria-label={`cancel ${job.name}`}
className={UPLOAD_VERB}
>
<X className="size-3.5" />
</button>
</li>
)
})}
</ul>
)
}
const UPLOAD_VERB =
"grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground"
/** The / menu, hung under the cursor: the blocks the paragraph can become, the one
* Enter takes marked, each with the keys that make it without the menu. The keys
* stay in the document (editor-slash); a press here chooses. Drawn as menu's popup
* and rows, in the editor's material and scheme. */
function SlashMenu({
state,
material,
scheme,
}: {
state: SlashState
material: Material
scheme: WornScheme
}) {
return createPortal(
<div
role="listbox"
aria-label="turn into"
data-slot="editor-slash"
data-material={material}
className={cn(
schemeOf(scheme),
SLASH[material].popup,
"fixed top-(--ag-slash-top) left-(--ag-slash-left) z-50 min-w-56 p-1",
)}
style={
{
"--ag-slash-top": `${state.at.bottom + 6}px`,
"--ag-slash-left": `${state.at.left - 8}px`,
} as React.CSSProperties
}
>
{state.choices.map((c, i) => (
<div
key={c.id}
role="option"
aria-selected={i === state.marked}
tabIndex={-1}
// A press chooses without taking focus from the document first.
onPointerDown={(e) => {
e.preventDefault()
state.choose(i)
}}
className={cn(
"flex cursor-pointer select-none items-center gap-6 px-2 py-1.5 text-[13px] text-current/85 aria-selected:text-current",
SLASH[material].row,
)}
>
<span className="flex-1">{c.label}</span>
{c.keys && <Hints hint={c.keys} keysOnly className="opacity-50" />}
</div>
))}
</div>,
document.body,
)
}import { serialize } from "wordgard/doc"
import { menuBar, Panel, placeholder, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import {
blockDoc,
blockquote,
bulletList,
code,
codeBlock,
codeBlockLanguage,
emphasis,
heading,
horizontalRule,
image,
lineBreak,
link,
orderedList,
paragraph,
strikethrough,
strong,
} from "wordgard/schema"
import { GardState } from "wordgard/state"
import { tables } from "wordgard/table"
import { Blockquote, Image as ImageNode } from "wordgard/types"
import { structureKeys } from "@/registry/base-nova/ui/editor-keys"
import {
htmlToMarkdown,
markdownInput,
markdownToHtml,
} from "@/registry/base-nova/ui/editor-markdown"
import {
type SlashState,
slashMenu,
} from "@/registry/base-nova/ui/editor-slash"
import type { MediaUploader } from "@/registry/base-nova/ui/upload"
/** What the document is read from and written as: HTML (a body a site renders), or
* markdown (a file; editor-markdown). */
export type EditorFormat = "html" | "markdown"
export type EditorJob = {
id: string
name: string
/** The first picture being sent, as an object URL, released when the job ends. */
preview?: string
progress: number
error?: string
retry: () => void
cancel: () => void
}
type Job = EditorJob & {
from: number
to: number
controller: AbortController
files: File[]
/** A toolbar upload is Wordgard's own promise: it resolves with the URL for the
* dialog's field, or rejects when the job is cancelled or the editor goes away, so
* the dialog never receives an empty string as a picture. Paste and drop have no
* promise; they insert mapped nodes. */
settle?: { resolve: (src: string) => void; reject: (error: Error) => void }
onProgress?: (percent: number) => void
}
export function mountEditor(
host: HTMLElement,
opts: {
/** Where Wordgard's toolbar and its dialogs (an image's, a link's) are placed: an
* element the editor draws itself, so the toolbar's row (sticky on a page, inside
* a field's well, glass) is the editor's own. */
toolbar: HTMLElement
/** The document's look, on the editable node itself (markdown's MARKDOWN), so a
* rule on its first and last block reaches them and the toolbar wears none of it. */
documentClass: string
/** In `format`: HTML, or a markdown file's text. */
defaultValue: string
format: EditorFormat
label: string
placeholder?: string
images: boolean
upload?: MediaUploader
/** The document in `format`, after every change. */
onChange: (value: string) => void
onJobs: (jobs: EditorJob[]) => void
/** The / menu: what it shows, null when closed (editor-slash). */
onSlash: (state: SlashState | null) => void
},
) {
const jobs = new Map<string, Job>()
let alive = true
const notify = () => {
if (alive) opts.onJobs([...jobs.values()])
}
const run = async (job: Job, wg: Wordgard) => {
const handler = opts.upload
if (!handler) return
job.controller.abort()
const controller = new AbortController()
job.controller = controller
job.error = undefined
job.progress = 0
// The gate is the file, before a byte moves: a video dropped into an image slot is
// refused here, not after it has been uploaded and turned away.
const wrong = job.files.find((file) => !file.type.startsWith("image/"))
if (wrong) {
job.error = `${wrong.name} is not an image`
notify()
return
}
notify()
try {
const progress = job.files.map(() => 0)
const assets = await Promise.all(
job.files.map((file, index) =>
handler(file, {
signal: controller.signal,
onProgress: (value) => {
if (
alive &&
!controller.signal.aborted &&
job.controller === controller
) {
progress[index] = value
job.progress =
progress.reduce((a, b) => a + b, 0) / progress.length
// Wordgard creates its progress element after invoking the uploader.
const percent = job.progress * 100
queueMicrotask(() => {
if (alive && !controller.signal.aborted)
job.onProgress?.(percent)
})
notify()
}
},
}),
),
)
if (!alive || controller.signal.aborted || !jobs.has(job.id)) return
// The server's contract, not the file's: an image went up, an image comes back.
if (assets.some((asset) => asset.kind === "video"))
throw new Error("the server sent back a video for an image")
const first = assets[0]
if (!first) throw new Error("the server sent back nothing")
end(job)
if (job.settle) job.settle.resolve(first.src)
else
wg.dispatch({
changes: {
from: job.from,
to: job.to,
insert: assets.map((asset) => ImageNode.of(asset.src)),
fit: true,
},
userEvent: "input.image",
})
notify()
} catch (error) {
if (!alive || controller.signal.aborted) return
controller.abort()
job.error = error instanceof Error ? error.message : "it did not go up"
notify()
}
}
/** A job done or given up: gone from the list, its preview let go. */
const end = (job: Job) => {
jobs.delete(job.id)
if (job.preview) URL.revokeObjectURL(job.preview)
}
const enqueue = (
files: File[],
wg: Wordgard,
from: number,
to = from,
toolbar?: {
resolve: (src: string) => void
reject: (error: Error) => void
progress: (percent: number) => void
},
) => {
const id = crypto.randomUUID()
const job: Job = {
id,
files,
settle: toolbar && { resolve: toolbar.resolve, reject: toolbar.reject },
onProgress: toolbar?.progress,
name: files.map((file) => file.name).join(", "),
preview: files[0]?.type.startsWith("image/")
? URL.createObjectURL(files[0])
: undefined,
from,
to,
progress: 0,
controller: new AbortController(),
retry: () => void run(job, wg),
cancel: () => {
job.controller.abort()
end(job)
job.settle?.reject(new Error("upload cancelled"))
notify()
},
}
jobs.set(id, job)
void run(job, wg)
}
const paste = Wordgard.domEventHandler("paste", (event, wg) => {
if (!opts.upload || !opts.images || !event.clipboardData) return false
const files = [...event.clipboardData.files].filter((f) =>
f.type.startsWith("image/"),
)
if (!files.length) return false
event.preventDefault()
const { anchor, head } = wg.state.selection
enqueue(files, wg, Math.min(anchor, head), Math.max(anchor, head))
return true
})
// Capture the drop position now, then map through edits. Upstream resolves screen coordinates
// only after upload, which can point at different text after scrolling or typing.
const drop = Wordgard.domEventHandler("drop", (event, wg) => {
if (!opts.upload || !opts.images || !event.dataTransfer) return false
const files = [...event.dataTransfer.files].filter((f) =>
f.type.startsWith("image/"),
)
if (!files.length) return false
event.preventDefault()
enqueue(
files,
wg,
wg.posAtCoords({ x: event.clientX, y: event.clientY }).pos,
)
return true
})
const markdown = opts.format === "markdown"
const editor = Wordgard.create({
doc: markdown ? markdownToHtml(opts.defaultValue) : opts.defaultValue,
parent: host,
config: [
blockDoc(),
paragraph(),
heading(),
lineBreak(),
strong(),
emphasis(),
code(),
link(),
// List items hold blocks in either format: a paragraph typed `- ` or `1. ` is
// wrapped as one, and a list can nest. A markdown file also holds code, rules,
// struck text and tables; an HTML body keeps the narrower set its consumers
// render. Markdown typed or pasted becomes what it means in both.
bulletList({ blockItems: true }),
orderedList({ blockItems: true }),
// blockquote() without its theme: Wordgard's grey bar and indent are unlayered
// and beat markdown's quote (a wash, the quote voice, the mark in the gutter).
GardState.schemaElement.of(Blockquote),
blockquote.button,
blockquote.createOnGT,
markdownInput(markdown),
structureKeys(markdown),
...slashMenu(markdown, opts.onSlash),
...(markdown
? [
codeBlock(),
codeBlockLanguage(),
horizontalRule(),
strikethrough(),
tables({ cellSpanning: false, cellContent: "inline" }),
]
: []),
history(),
menuBar(),
Panel.configure({ topContainer: opts.toolbar }),
// Panels placed outside the editor are not counted as covering it; a sticky
// toolbar does cover the document's top, so the caret is scrolled clear of it.
Wordgard.coveredMargins.of((wg) => ({
top: Math.max(
0,
opts.toolbar.getBoundingClientRect().bottom -
Math.max(0, wg.scrollDOM.getBoundingClientRect().top),
),
})),
Wordgard.contentAttributes.of({
class: opts.documentClass,
"aria-label": opts.label,
}),
Wordgard.colorScheme.of(host.closest(".dark") ? "dark" : "light"),
...(opts.placeholder ? [placeholder(opts.placeholder)] : []),
...(opts.images ? [image()] : []),
...(opts.images && opts.upload
? [
GardState.prec.highest([paste, drop]),
image.uploader.of(
(file, wg, progress) =>
new Promise<string>((resolve, reject) => {
enqueue([file], wg, 0, 0, { resolve, reject, progress })
}),
),
]
: []),
Wordgard.updateListener.of((update) => {
for (const job of jobs.values()) {
if (job.settle) continue
const collapsed = job.from === job.to
job.from = update.changes.mapPos(job.from, collapsed ? -1 : 1)
job.to = collapsed
? job.from
: Math.max(job.from, update.changes.mapPos(job.to, -1))
}
if (update.docChanged) {
const html = serialize(update.state.doc).toHTML()
opts.onChange(markdown ? htmlToMarkdown(html) : html)
}
}),
],
})
return () => {
alive = false
for (const job of [...jobs.values()]) {
job.controller.abort()
job.settle?.reject(new Error("the editor closed"))
end(job)
}
editor.dom.remove()
// The toolbar lives outside the editor's own node; it goes with it.
opts.toolbar.replaceChildren()
}
}// MARKDOWN IN AND OUT OF THE EDITOR, for a document whose file is markdown (a README, a
// note, a post body): the text is parsed by markdown's one parse (lib/markdown-core: mdast,
// GitHub's extensions, front matter), every block the editor can hold becomes its own
// node, and the
// document is written back as markdown on every change.
//
// NOTHING IS LOST. A block the editor cannot hold as itself (raw HTML, front matter,
// footnotes, link reference definitions, a reference link, a task list, an aligned
// table, a link or image with a title, a fence with meta) keeps its exact source: it
// shows as a code block marked `verbatim` and is written back as it came, byte for
// byte. What the editor holds is written in one style (`-` bullets, `*` emphasis,
// fenced code), so a file that never changes is never rewritten, and a file that
// does reads as one hand wrote it. Soft line breaks inside a paragraph become spaces.
//
// TYPED AND PASTED TOO (`markdownInput`, at the end): what a hand writes as markdown
// becomes what it means as it is typed, in either format.
//
// Browser-only: HTML is read with DOMParser.
import type * as M from "mdast"
import { frontmatterToMarkdown } from "mdast-util-frontmatter"
import { gfmToMarkdown } from "mdast-util-gfm"
import { toMarkdown } from "mdast-util-to-markdown"
import { toggleMark } from "wordgard/command"
import { Leaf, Mark, parse } from "wordgard/doc"
import { InputRule, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import { Code, Emphasis, Link, Strikethrough, Strong } from "wordgard/types"
import {
FRONTMATTER,
parseMarkdown,
} from "@/registry/base-nova/lib/markdown-core"
/** The code-block language that marks a block kept as its source. */
export const VERBATIM = "verbatim"
const escapeHtml = (s: string) =>
s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
const PHRASING = new Set([
"text",
"emphasis",
"strong",
"delete",
"inlineCode",
"link",
"image",
"break",
])
const BLOCKS = new Set([
"paragraph",
"heading",
"blockquote",
"list",
"listItem",
"code",
"thematicBreak",
"table",
"tableRow",
"tableCell",
])
/** Whether the editor holds this node and everything in it as itself. */
function held(node: M.Nodes): boolean {
if (!PHRASING.has(node.type) && !BLOCKS.has(node.type)) return false
if ((node.type === "link" || node.type === "image") && node.title)
return false
if (node.type === "listItem" && node.checked != null) return false
if (node.type === "code" && node.meta) return false
if (node.type === "table" && node.align?.some((a) => a !== null)) return false
return "children" in node ? (node.children as M.Nodes[]).every(held) : true
}
const lines = (text: string) => escapeHtml(text).replace(/\n/g, "<br>")
function inlineHtml(nodes: readonly M.PhrasingContent[]): string {
return nodes
.map((n) => {
switch (n.type) {
case "text":
return escapeHtml(n.value.replace(/\n/g, " "))
case "emphasis":
return `<em>${inlineHtml(n.children)}</em>`
case "strong":
return `<strong>${inlineHtml(n.children)}</strong>`
case "delete":
return `<s>${inlineHtml(n.children)}</s>`
case "inlineCode":
return `<code>${escapeHtml(n.value)}</code>`
case "link":
return `<a href="${escapeHtml(n.url)}">${inlineHtml(n.children)}</a>`
case "image":
return `<img src="${escapeHtml(n.url)}" alt="${escapeHtml(n.alt ?? "")}">`
case "break":
return "<br>"
default:
throw new Error(`editor-markdown: ${n.type} is not held inline`)
}
})
.join("")
}
function blockHtml(node: M.RootContent, source: string): string {
if (!held(node)) {
const { start, end } = node.position ?? {}
if (start?.offset === undefined || end?.offset === undefined)
throw new Error(`editor-markdown: ${node.type} has no source position`)
return `<pre data-language="${VERBATIM}">${lines(source.slice(start.offset, end.offset))}</pre>`
}
switch (node.type) {
case "paragraph":
return `<p>${inlineHtml(node.children)}</p>`
case "heading":
return `<h${node.depth}>${inlineHtml(node.children)}</h${node.depth}>`
case "blockquote":
return `<blockquote>${node.children.map((c) => blockHtml(c, source)).join("")}</blockquote>`
case "list": {
const items = node.children
.map((item) => {
const [only, ...rest] = item.children
return only?.type === "paragraph" && rest.length === 0
? `<li>${inlineHtml(only.children)}</li>`
: `<li>${item.children.map((c) => blockHtml(c, source)).join("")}</li>`
})
.join("")
return node.ordered
? `<ol${node.start && node.start !== 1 ? ` start="${node.start}"` : ""}>${items}</ol>`
: `<ul>${items}</ul>`
}
case "code":
return `<pre${node.lang ? ` data-language="${escapeHtml(node.lang)}"` : ""}>${lines(node.value)}</pre>`
case "thematicBreak":
return "<hr>"
case "table": {
const [head, ...body] = node.children
const row = (r: M.TableRow, cell: "th" | "td") =>
`<tr>${r.children.map((c) => `<${cell}>${inlineHtml(c.children)}</${cell}>`).join("")}</tr>`
return `<table>${head ? row(head, "th") : ""}${body.map((r) => row(r, "td")).join("")}</table>`
}
default:
throw new Error(`editor-markdown: ${node.type} is not held as a block`)
}
}
/** A markdown file as the editor's HTML. */
export function markdownToHtml(markdown: string): string {
const tree = parseMarkdown(markdown)
return (
tree.children.map((node) => blockHtml(node, markdown)).join("") || "<p></p>"
)
}
// ── back out: the editor's HTML as markdown.
/** A code block's text: its line breaks are `<br>`s. */
function preText(el: Element): string {
return [...el.childNodes]
.map((n) =>
n.nodeName === "BR"
? "\n"
: n instanceof Element
? preText(n)
: (n.textContent ?? ""),
)
.join("")
}
function phrasing(el: Element): M.PhrasingContent[] {
return [...el.childNodes].flatMap((n): M.PhrasingContent[] => {
if (!(n instanceof Element))
return n.textContent ? [{ type: "text", value: n.textContent }] : []
switch (n.tagName) {
case "STRONG":
case "B":
return [{ type: "strong", children: phrasing(n) }]
case "EM":
case "I":
return [{ type: "emphasis", children: phrasing(n) }]
case "S":
case "DEL":
case "STRIKE":
return [{ type: "delete", children: phrasing(n) }]
case "CODE":
return [{ type: "inlineCode", value: n.textContent ?? "" }]
case "A":
return [
{
type: "link",
url: n.getAttribute("href") ?? "",
children: phrasing(n),
},
]
case "IMG":
return [
{
type: "image",
url: n.getAttribute("src") ?? "",
alt: n.getAttribute("alt") ?? "",
},
]
case "BR":
return [{ type: "break" }]
default:
return phrasing(n)
}
})
}
const BLOCK_TAGS = new Set([
"P",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"BLOCKQUOTE",
"UL",
"OL",
"PRE",
"HR",
"TABLE",
])
function blocks(el: Element): M.RootContent[] {
const out: M.RootContent[] = []
// Inline content loose at block level (a list item's own text) is one paragraph.
let loose: Node[] = []
const flush = () => {
if (loose.some((n) => n.textContent?.trim() || n.nodeName === "IMG")) {
const holder = el.ownerDocument.createElement("p")
for (const n of loose) holder.append(n.cloneNode(true))
out.push({ type: "paragraph", children: phrasing(holder) })
}
loose = []
}
for (const n of [...el.childNodes]) {
if (!(n instanceof Element) || !BLOCK_TAGS.has(n.tagName)) {
loose.push(n)
continue
}
flush()
out.push(block(n))
}
flush()
return out
}
function block(el: Element): M.RootContent {
switch (el.tagName) {
case "P":
return { type: "paragraph", children: phrasing(el) }
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6":
return {
type: "heading",
depth: Number(el.tagName[1]) as M.Heading["depth"],
children: phrasing(el),
}
case "BLOCKQUOTE":
return {
type: "blockquote",
children: blocks(el) as M.BlockContent[],
}
case "UL":
case "OL":
return {
type: "list",
ordered: el.tagName === "OL",
start:
el.tagName === "OL" ? Number(el.getAttribute("start") ?? "1") : null,
spread: false,
children: [...el.children]
.filter((li) => li.tagName === "LI")
.map(
(li): M.ListItem => ({
type: "listItem",
spread: false,
children: blocks(li) as M.BlockContent[],
}),
),
}
case "PRE": {
const language = el.getAttribute("data-language")
const text = preText(el)
return language === VERBATIM
? { type: "html", value: text }
: { type: "code", lang: language || null, value: text }
}
case "HR":
return { type: "thematicBreak" }
case "TABLE": {
const rows = [...el.querySelectorAll("tr")].map(
(tr): M.TableRow => ({
type: "tableRow",
children: [...tr.children].map(
(cell): M.TableCell => ({
type: "tableCell",
children: phrasing(cell),
}),
),
}),
)
return {
type: "table",
align: (rows[0]?.children ?? []).map(() => null),
children: rows,
}
}
default:
throw new Error(`editor-markdown: <${el.tagName}> is not a block`)
}
}
/** The editor's HTML as a markdown file. */
export function htmlToMarkdown(html: string): string {
const body = new DOMParser().parseFromString(html, "text/html").body
return toMarkdown(
{ type: "root", children: blocks(body) },
{
extensions: [gfmToMarkdown(), frontmatterToMarkdown([...FRONTMATTER])],
bullet: "-",
emphasis: "*",
strong: "*",
rule: "-",
fence: "`",
fences: true,
listItemIndent: "one",
},
)
}
// ── markdown typed and pasted: what a hand writes as markdown becomes the thing it
// means, in either format. The block shortcuts (`# `, `> `, `- `, `1. `, three
// backticks, `---`) are Wordgard's own, on the blocks that carry them; the inline ones
// are here, closed by their last delimiter: `**strong**` or `__strong__`, `*em*` or
// `_em_`, `` `code` ``, `~~struck~~` where struck text exists, `[words](url)`. Plain
// text pasted that reads as markdown arrives as what it means, through the same
// conversion a file goes through; any other text arrives as text. A web address pasted
// over chosen words links them; one typed links itself at the space after it.
/** A span closed by typing: group 1 is the whole of it, delimiters included, group 2
* its words; `mark` gives the mark from the match (a link's target is group 3). */
function closing(
expr: RegExp,
mark: (match: InputRule.MatchArray) => Mark | null,
): InputRule {
return InputRule.define({
expr,
apply: (_state, match) => {
const whole = match[1]
const words = match[2]
const m = mark(match)
if (!whole || !words || !m) return null
return {
changes: {
from: whole.from.pos,
to: whole.to.pos,
insert: [Leaf.text(words.text, m.addToSet(Mark.none))],
},
annotations: history.isolate.of(true),
}
},
})
}
/** Text that reads as markdown: a block's opening or an inline span's delimiters. */
const MARKDOWNISH =
/^(?:#{1,6} |[-*+] |\d+[.)] |> |```|---\s*$|\|.+\|\s*$)|\*\*\S|__\S|`[^`\n]+`|~~\S|\[[^\]\n]+\]\([^)\s]+\)/m
/** The markdown typed and pasted, for a schema that holds struck text or not. */
export function markdownInput(struck: boolean) {
return [
closing(/(\*\*([^*\s](?:[^*]*[^*\s])?)\*\*)$/, () => Strong),
closing(/(?:^|[^_\w])(__([^_\s](?:[^_]*[^_\s])?)__)$/, () => Strong),
closing(/(?:^|[^*\w])(\*([^*\s](?:[^*]*[^*\s])?)\*)$/, () => Emphasis),
closing(/(?:^|[^_\w])(_([^_\s](?:[^_]*[^_\s])?)_)$/, () => Emphasis),
closing(/(`([^`]+)`)$/, () => Code),
...(struck
? [closing(/(~~([^~\s](?:[^~]*[^~\s])?)~~)$/, () => Strikethrough)]
: []),
closing(/(\[([^\]]+)\]\(([^)\s]+)\))$/, (match) =>
match[3] ? Link.of(match[3].text) : null,
),
// A web address typed, then a space: it links itself, a sentence's closing
// punctuation left out of it.
InputRule.define({
expr: /(?:^|\s)(https?:\/\/[^\s<>]*[^\s<>.,;:!?)'"\]])([.,;:!?)'"\]]*\s)$/,
apply: (_state, match) => {
const url = match[1]
const after = match[2]
if (!url || !after) return null
return {
changes: {
from: url.from.pos,
to: after.to.pos,
insert: [
Leaf.text(url.text, Link.of(url.text).addToSet(Mark.none)),
Leaf.text(after.text),
],
},
annotations: history.isolate.of(true),
userEvent: "input.autolink",
}
},
}),
Wordgard.clipboardTextParser.of((text, state) => {
if (!MARKDOWNISH.test(text)) return null
const holder = document.createElement("div")
holder.innerHTML = markdownToHtml(text)
return parse.slice(state.schema, holder).slice
}),
// A web address pasted over chosen words makes them its link, as a chat or a doc
// does; over nothing it arrives as text.
Wordgard.domEventHandler("paste", (event, wg) => {
const url = event.clipboardData?.getData("text/plain").trim() ?? ""
if (wg.state.selection.isCursor || !/^https?:\/\/\S+$/.test(url))
return false
const linked = toggleMark(wg, Link.of(url))
if (!linked) return false
event.preventDefault()
wg.dispatch(linked)
return true
}),
]
}// THE EDITOR'S KEYS, the ones a document app has taught every hand. Structure: Tab
// nests a list item under the one above it, ⇧Tab brings it back out a level (⌘] and ⌘[
// the same), Tab in a code block indents its line; Enter on an empty item leaving the
// list and Backspace at an item's start joining it up are Wordgard's own. Anywhere
// else Tab is left alone (a table's cells keep theirs, the page its focus). Blocks and
// marks, as Google Docs has them: ⌘⌥1 to ⌘⌥6 a heading (again: back to a paragraph),
// ⌘⌥0 a paragraph, ⌘⇧7 a numbered list, ⌘⇧8 a bulleted one, ⌘⇧X struck, and Notion's
// ⌘E for code and ⌘⌥8 for a code block. Wordgard's own (⌘B, ⌘I, ⌘K, ⌃⇧1…) stay.
//
// The document is a stream of tokens, each one position wide: a plot's opening tag,
// its content, its `Plot.End`. Nesting and un-nesting an item are a few tokens moved,
// and lists that end up side by side are joined by Wordgard's own auto-join.
import {
autoJoinBlocks,
Command,
setTextblockType,
toggleList,
toggleMark,
} from "wordgard/command"
import { Leaf, Node, Plot, type Pos } from "wordgard/doc"
import { KeyBinding } from "wordgard/editor"
import type { GardState } from "wordgard/state"
import {
BulletList,
Code,
CodeBlock,
Heading,
OrderedList,
Paragraph,
Strikethrough,
} from "wordgard/types"
/** The list item the cursor is in: its innermost block whose parent is a list. */
function itemAt(state: GardState): Pos.Plot | null {
if (!state.selection.isCursor) return null
for (let p = state.sel.head.textblockParent; p; p = p.parent)
if (p.parent?.node.type.hasRole(Node.Role.List)) return p
return null
}
/** Tab: the item goes to the end of the item above it, in a list of its own kind
* (joined to one already there). The first item of a list has nothing to nest under
* and stays, the key still taken so focus does not leave the document. */
const indent: Command = (wg) => {
const item = itemAt(wg.state)
if (!item) return false
if (item.index === 0) return true
const list = item.parent as Pos.Plot
return autoJoinBlocks(wg.state, {
changes: [
// the item above stays open, a list opening inside it…
{
from: item.before - 1,
to: item.before,
insert: [list.node.tag.split(false)],
},
// …which closes after the item, and then the item above closes
{ from: item.after, insert: [Plot.End, Plot.End] },
],
scrollIntoView: true,
userEvent: "indent.list",
})
}
/** ⇧Tab: a nested item comes out to follow the item it was in; the items after it come
* with it, nested under it, so nothing changes order. A top-level item, or one whose
* list has more of its parent after it, stays. */
const outdent: Command = (wg) => {
const item = itemAt(wg.state)
if (!item) return false
const list = item.parent as Pos.Plot
const parent = list.parent
if (
!parent?.parent?.node.type.hasRole(Node.Role.List) ||
list.index !== parent.node.content.length - 1
)
return true
const last = item.index === list.node.content.length - 1
return autoJoinBlocks(wg.state, {
changes: [
item.index === 0
? // the list opened just for it goes; the parent closes there instead
{ from: list.before, to: list.before + 1, insert: [Plot.End] }
: // the list and the parent close before it
{ from: item.before, insert: [Plot.End, Plot.End] },
last
? // the closes that followed it are spent
{ from: item.after, to: item.after + 2 }
: // the items after it open a list inside it, closed by what closed them
{
from: item.after - 1,
to: item.after,
insert: [list.node.tag.split(false)],
},
],
scrollIntoView: true,
userEvent: "outdent.list",
})
}
/** Tab in a code block: two spaces at the cursor. */
const indentCode: Command = (wg) => {
const { state } = wg
const block = state.sel.head.textblockParent
if (!state.selection.isCursor || !block?.node.type.hasRole(Node.Role.Code))
return false
return {
changes: { from: state.sel.head.pos, insert: [Leaf.text(" ")] },
userEvent: "input.indent",
}
}
const either =
(...commands: Command[]): Command =>
(wg, param) => {
for (const command of commands) {
const done = command(wg, param)
if (done) return done
}
return false
}
/** The block a key names, or a paragraph when the cursor's block already is one: the
* same key sets it and takes it back. */
const blockType =
(tag: Plot.Tag): Command =>
(wg) => {
const block = wg.state.sel.head.textblockParent
return setTextblockType(wg, block?.node.tag.eq(tag) ? Paragraph : tag)
}
/** A tag Wordgard's types always make (a heading of a level, an ordered list from 1). */
const made = <T>(tag: Plot.Tag<T> | null, what: string) => {
if (!tag) throw new Error(`editor-keys: Wordgard made no ${what}`)
return tag
}
const heading = (level: number) =>
made(Heading.of(level), `heading of level ${level}`)
/** The keys, for the editor's config. `rich`: the schema holds code blocks and struck
* text (a markdown file's does), so their keys are bound. */
export function structureKeys(rich: boolean) {
return [
KeyBinding.of({
key: "Tab",
run: either(indentCode, indent),
shift: outdent,
}),
KeyBinding.of({ key: "Mod-]", run: indent }),
KeyBinding.of({ key: "Mod-[", run: outdent }),
KeyBinding.of({
key: "Mod-Alt-0",
run: Command.bind(setTextblockType, Paragraph),
}),
...[1, 2, 3, 4, 5, 6].map((level) =>
KeyBinding.of({
key: `Mod-Alt-${level}`,
run: blockType(heading(level)),
}),
),
KeyBinding.of({
key: "Mod-Shift-7",
run: Command.bind(toggleList, made(OrderedList.default, "ordered list")),
}),
KeyBinding.of({
key: "Mod-Shift-8",
run: Command.bind(toggleList, BulletList),
}),
KeyBinding.of({ key: "Mod-e", run: Command.bind(toggleMark, Code) }),
...(rich
? [
KeyBinding.of({
key: "Mod-Shift-x",
run: Command.bind(toggleMark, Strikethrough),
}),
KeyBinding.of({ key: "Mod-Alt-8", run: blockType(CodeBlock) }),
]
: []),
]
}// THE / MENU, as Notion taught it: in an empty paragraph, `/` offers the blocks the
// document can hold, what follows the slash narrows them (by name or a word for them:
// `/h2`, `/quote`, `/todo`… whatever the words say), ↑ ↓ walk them, Enter or Tab turn
// the paragraph into the one marked, Esc leaves the slash as typed. The words typed
// are taken away with the choice. Only at the start of an empty paragraph: a slash in
// the middle of a sentence (a path, a date, "and/or") is a slash.
//
// The engine knows where the menu is and what it offers and runs the choice; the
// editor's React draws it from what `report` says (`SlashState`), at the cursor.
import {
type Command,
setTextblockType,
toggleBlock,
toggleList,
} from "wordgard/command"
import { ChangeSet, type Plot } from "wordgard/doc"
import { KeyBinding, Wordgard } from "wordgard/editor"
import { history } from "wordgard/history"
import { GardSelection, GardState } from "wordgard/state"
import {
Blockquote,
BulletList,
CodeBlock,
Heading,
HorizontalRule,
OrderedList,
Paragraph,
} from "wordgard/types"
/** One block the menu offers, as React draws it. */
export interface SlashChoice {
id: string
label: string
/** the keys that make it without the menu */
keys?: string
}
/** What the menu shows, or null while it is closed. */
export interface SlashState {
query: string
choices: readonly SlashChoice[]
/** which choice Enter takes */
marked: number
/** where the cursor is on screen: the menu hangs under it */
at: { left: number; top: number; bottom: number }
/** a choice pressed with the pointer */
choose: (i: number) => void
}
interface Block extends SlashChoice {
/** words it answers to besides its label */
words: string
run: Command
}
/** The longest a query runs before a slash is taken for text. */
const QUERY_MAX = 24
const made = <T>(tag: Plot.Tag<T> | null, what: string) => {
if (!tag) throw new Error(`editor-slash: Wordgard made no ${what}`)
return tag
}
/** A rule where the paragraph was, the cursor after it: the `---` rule's own move. */
const rule: Command = (wg) => {
const block = wg.state.sel.head.textblockParent
if (!block) return false
const changes = ChangeSet.create(wg.state.doc, {
from: block.start,
to: block.end,
insert: [HorizontalRule],
fit: true,
})
const hr = changes.findInserted((t) => t === HorizontalRule)
if (hr == null) return false
return {
changes,
selection: (cx) => GardSelection.near(cx, hr + 1, 1),
userEvent: "insert.horizontalrule",
}
}
/** The blocks this document holds, in the order a writer reaches for them. */
function blocksOf(rich: boolean): Block[] {
const set =
(tag: Plot.Tag<unknown>): Command =>
(wg) =>
setTextblockType(wg, tag)
return [
{
id: "text",
label: "text",
words: "paragraph plain",
keys: "mod+alt+0",
run: set(Paragraph),
},
...[1, 2, 3].map((level) => ({
id: `h${level}`,
label: `heading ${level}`,
words: `h${level} title`,
keys: `mod+alt+${level}`,
run: set(made(Heading.of(level), `heading ${level}`)),
})),
{
id: "bullets",
label: "bulleted list",
words: "ul unordered bullet list -",
keys: "mod+shift+8",
run: (wg) => toggleList(wg, BulletList),
},
{
id: "numbers",
label: "numbered list",
words: "ol ordered number list 1.",
keys: "mod+shift+7",
run: (wg) => toggleList(wg, made(OrderedList.default, "ordered list")),
},
{
id: "quote",
label: "quote",
words: "blockquote citation >",
run: (wg) => toggleBlock(wg, Blockquote),
},
...(rich
? [
{
id: "code",
label: "code block",
words: "pre snippet ```",
keys: "mod+alt+8",
run: set(CodeBlock),
},
{
id: "rule",
label: "divider",
words: "hr rule line separator ---",
run: rule,
},
]
: []),
]
}
const matches = (b: Block, query: string) => {
const q = query.trim().toLowerCase()
return (
q === "" ||
b.label.includes(q) ||
b.words.split(" ").some((w) => w.startsWith(q))
)
}
/** The menu, for the editor's config: `report` is told whenever what it shows
* changes, null once it closes. */
export function slashMenu(
rich: boolean,
report: (state: SlashState | null) => void,
) {
const blocks = blocksOf(rich)
// Where the slash and its query are, while the menu is open.
let open: { from: number; to: number; query: string } | null = null
let marked = 0
// A slash Esc dismissed: left as text until the paragraph is emptied again.
let dismissed: number | null = null
let shown: Block[] = []
const publish = (wg: Wordgard) => {
if (!open || shown.length === 0) return report(null)
const rect = wg.coordsAtPos(open.to)
report({
query: open.query,
choices: shown.map(({ id, label, keys }) => ({ id, label, keys })),
marked,
at: { left: rect.left, top: rect.top, bottom: rect.bottom },
choose: (i) => choose(wg, i),
})
}
/** The slash's place, read from the state: an empty paragraph's text from its
* start to the cursor reading `/query`. */
const read = (state: GardState) => {
const sel = state.selection
const block = state.sel.head.textblockParent
if (!sel.isCursor || !block?.node.tag.eq(Paragraph)) return null
const text = block.node.textContent()
const head = state.sel.head.pos
const typed = /^\/([^\n/]*)$/.exec(text)
if (!typed || head !== block.end) return null
const query = typed[1] ?? ""
if (query.length > QUERY_MAX) return null
return { from: block.start, to: head, query }
}
const close = (wg: Wordgard) => {
open = null
publish(wg)
}
function choose(wg: Wordgard, i: number) {
const block = shown[i]
if (!open || !block) return
const { from, to } = open
open = null
report(null)
// The slash and its words go, as one step undone with the choice.
wg.dispatch({
changes: { from, to },
annotations: history.isolate.of(true),
userEvent: "delete.slash",
})
const spec = block.run(wg, null)
if (spec && spec !== true) wg.dispatch(spec)
wg.focus()
}
const keys = GardState.prec.highest(
["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].map((key) =>
KeyBinding.of({
key,
run: (wg) => {
if (!open || shown.length === 0) return false
if (key === "Escape") {
dismissed = open.from
close(wg)
return true
}
if (key === "ArrowDown" || key === "ArrowUp") {
const n = shown.length
marked = (marked + (key === "ArrowDown" ? 1 : -1) + n) % n
publish(wg)
return true
}
choose(wg, marked)
return true
},
}),
),
)
const watch = Wordgard.updateListener.of((update) => {
const wg = update.editor
// Leaving the document closes the menu; coming back reads the slash again.
if (update.focusChanged && !wg.hasFocus) {
if (open) close(wg)
return
}
if (!update.docChanged && !update.selectionSet && !update.focusChanged)
return
const now = read(update.state)
if (!now) {
dismissed = null
if (open) close(wg)
return
}
if (dismissed === now.from) return
const changed = open?.query !== now.query
open = now
shown = blocks.filter((b) => matches(b, now.query))
if (changed) marked = 0
publish(wg)
})
return [keys, watch]
}/* biome-ignore-all lint/complexity/noImportantStyles: every rule overrides a style Wordgard injects at runtime, unlayered */
/* WORDGARD'S OWN NODES, restyled. Everything the editor draws itself (the field's
well or glass, the row the tools sit in, the document's ring, its padding through
--ag-editor-pad and --ag-editor-min) is utilities in editor.tsx. What is here is
what Wordgard creates at runtime and styles with a sheet it injects without a
layer: an unlayered rule beats every utility whatever its specificity, so each
declaration here is !important (an important declaration in any layer beats an
unlayered normal one), inside @layer components. The colours are currentColor at
the alpha ladder's tiers, so one set of rules reads on the page, in a plain field
and on glass. */
@layer theme, base, components, utilities;
@layer components {
/* Wordgard's frame: its root, and the toolbar's node (it wears Wordgard's theme
classes too). Its border and focus outline go; its menus, dialogs, tooltips and
table picker draw with these variables, so they take the registry's colours
here. --wg-panel-color is a popup's ground (menu's plain popup: the popover). */
[data-slot="editor-document"] > *,
[data-slot="editor-panels"] {
--wg-highlight-color: currentColor !important;
--wg-border-color: color-mix(
in oklab,
currentColor 8%,
transparent
) !important;
--wg-panel-color: var(--popover) !important;
--wg-menu-color: currentColor !important;
--wg-menu-item-size: 1.125rem !important;
border: 0 !important;
outline: none !important;
}
/* The row the tools sit in is the editor's (data-slot=editor-toolbar): Wordgard's
panel group is only what holds them, with no ground or place of its own. */
[data-slot="editor-panels"] > wg-panels {
position: static !important;
background: transparent !important;
font: inherit !important;
}
[data-slot="editor-panels"] wg-menubar {
align-items: center !important;
gap: 0.125rem !important;
padding: 0 !important;
border: 0 !important;
color: inherit !important;
}
[data-slot="editor-panels"] wg-menu-spacer {
width: 0.5rem !important;
}
/* A tool: an icon, or the block's name, at the quiet tier; a wash under the hand;
on (the selection is bold, a heading) a wash and its words lit, never a fill.
The wash is a share of the tool's own colour, so it follows the words. 28px, the
registry's small icon button. */
[data-slot="editor-panels"] .wg-menu-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box !important;
height: 1.75rem !important;
min-width: 1.75rem !important;
padding: 0 0.3125rem !important;
border-radius: calc(var(--radius) * 0.8) !important;
background: transparent !important;
color: color-mix(in oklab, currentColor 50%, transparent) !important;
cursor: pointer;
transition:
background-color 150ms,
color 150ms;
}
[data-slot="editor-panels"]
.wg-menu-button:not([aria-disabled]):is(:hover, [aria-expanded="true"]) {
color: color-mix(in oklab, currentColor 85%, transparent) !important;
background: color-mix(in oklab, currentColor 7%, transparent) !important;
}
[data-slot="editor-panels"] .wg-menu-button[aria-pressed="true"] {
color: color-mix(in oklab, currentColor 92%, transparent) !important;
background: color-mix(in oklab, currentColor 9%, transparent) !important;
}
[data-slot="editor-panels"] .wg-button-label {
padding: 0 0.125rem !important;
line-height: 1.25rem;
}
/* Focus is @ag/tokens' ring, the keyboard's only (a press moves no focus here:
Wordgard keeps it in the document). Wordgard rings any focus in its own blue;
its ring is replaced by the tokens' line, a focused tool lit to the words'
colour so the ring reads as its outline. */
[data-slot="editor-panels"] .wg-menu-button:focus {
outline: none !important;
}
[data-slot="editor-panels"]
wg-menubar
> :is(.wg-menu-button, wg-submenu > .wg-menu-button):focus-visible:not(
[data-focus-pointer]
) {
color: color-mix(in oklab, currentColor 92%, transparent) !important;
outline: var(--focus-ring-width) solid
color-mix(in oklab, currentColor var(--focus-ring-alpha), transparent) !important;
outline-offset: var(--focus-ring-offset) !important;
}
/* A tool's menu (the block's kinds, the table's verbs): menu's plain popup and its
rows (menu.tsx's FACE.plain), a row lit by a wash under the hand or the keys
(its focus is that highlight, as a menu's row's is), the current one in full
words. Wordgard builds it inside its toolbar, so it cannot be the menu item. */
[data-slot="editor-panels"] wg-menu-list {
gap: 0 !important;
min-width: 11rem;
margin-top: 0.375rem;
padding: 0.25rem !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
background: var(--popover) !important;
color: var(--popover-foreground) !important;
line-height: 1.25rem !important;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
}
[data-slot="editor-panels"] wg-menu-list .wg-menu-button {
justify-content: flex-start !important;
width: 100% !important;
height: auto !important;
padding: 0.375rem 0.5rem !important;
color: color-mix(in oklab, currentColor 85%, transparent) !important;
}
[data-slot="editor-panels"]
wg-menu-list
.wg-menu-button:not([aria-disabled]):is(:hover, :focus) {
outline: none !important;
color: currentColor !important;
background: color-mix(in oklab, currentColor 8%, transparent) !important;
}
[data-slot="editor-panels"]
wg-menu-list
.wg-menu-button[aria-pressed="true"] {
color: currentColor !important;
background: transparent !important;
}
[data-slot="editor-panels"] .wg-dimension-cell {
stroke: color-mix(in oklab, currentColor 20%, transparent) !important;
}
/* Wordgard's dialogs (a picture's address, a link's) open in the toolbar's row,
under the tools: its buttons and fields on the registry's washes and hairlines. */
[data-slot="editor-panels"] wg-dialog {
margin-top: 0.25rem;
padding: 0.5rem 1.5rem 0.5rem 0.25rem !important;
border-bottom: 0 !important;
border-top: 1px solid color-mix(in oklab, currentColor 8%, transparent) !important;
}
[data-slot="editor-panels"] .wg-dialog-button {
padding: 0.25rem 0.625rem !important;
border-radius: calc(var(--radius) * 0.8) !important;
background: color-mix(in oklab, currentColor 8%, transparent) !important;
cursor: pointer;
}
[data-slot="editor-panels"] .wg-dialog-button:hover {
background: color-mix(in oklab, currentColor 12%, transparent) !important;
}
[data-slot="editor-panels"] .wg-dialog-close {
background: transparent !important;
color: color-mix(in oklab, currentColor 50%, transparent) !important;
cursor: pointer;
}
[data-slot="editor-panels"]
wg-dialog
input:not([type="radio"], [type="file"]) {
padding: 0.25rem 0.5rem;
border: 0;
border-radius: calc(var(--radius) * 0.8);
background: color-mix(in oklab, currentColor 4%, transparent);
box-shadow: inset 0 0 0 1px
color-mix(in oklab, currentColor 12%, transparent);
color: inherit;
font: inherit;
outline: none;
}
/* The document's node: its padding and its height when empty are the variant's
(editor.tsx), its look markdown's (MARKDOWN, on this node). */
[data-slot="editor-document"] wg-content {
padding: var(--ag-editor-pad) !important;
min-height: var(--ag-editor-min) !important;
}
[data-slot="editor-document"] wg-placeholder {
opacity: 1 !important;
color: color-mix(in oklab, var(--foreground) 40%, transparent) !important;
}
/* A chosen picture or rule: a quiet line round it, not Wordgard's blue. */
[data-slot="editor-document"] .wg-selected-node {
outline: 1.5px solid color-mix(in oklab, currentColor 35%, transparent) !important;
outline-offset: 2px;
}
[data-slot="editor-document"] wg-dropcursor {
border-color: currentColor !important;
}
/* A code block's language: a quiet word in its corner. */
[data-slot="editor-document"] select.wg-code-language {
background: transparent !important;
color: color-mix(in oklab, currentColor 45%, transparent) !important;
font: inherit !important;
font-size: 0.75em !important;
}
/* A link's address, shown under the hand: menu's plain popup, small. */
[data-slot="editor-document"] .wg-tooltip {
border: 1px solid var(--border) !important;
border-radius: calc(var(--radius) * 0.8) !important;
color: var(--popover-foreground) !important;
font-family: inherit !important;
font-size: 12px !important;
line-height: 1.5 !important;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
}
/* Wordgard's tables draw a full grid in their own border colour and select cells in
a light blue; the document's tables are markdown's (a hairline under the head and
between rows, no grid) and a selected cell is a wash of the page's own colour. */
[data-slot="editor-document"] wg-content :is(td, th) {
border: 0 !important;
border-bottom: 1px solid
color-mix(in oklab, var(--foreground) 5%, transparent) !important;
padding: 0.75em 1em !important;
}
[data-slot="editor-document"] wg-content th {
border-bottom-color: color-mix(
in oklab,
var(--foreground) 8%,
transparent
) !important;
}
[data-slot="editor-document"] wg-content .wg-selected-cell {
background: color-mix(
in oklab,
var(--foreground) 8%,
transparent
) !important;
}
}