Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
/** One request to the app, as a promise. */
export function ask(request) {
return new Promise((resolve, reject) => {
window.elyQuery({
request: JSON.stringify(request),
onSuccess: resolve,
onFailure: (code, message) => reject(new Error(`${code}: ${message}`)),
})
})
}
+44
View File
@@ -0,0 +1,44 @@
// Code blocks in CodeMirror; LaTeX and Mermaid preview.
import { basicSetup, EditorView } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { languages } from '@codemirror/language-data'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { theme } from './theme.js'
import { icons } from './icons.js'
import { render } from './math.js'
/** Previews for one editor's code blocks. */
function previews(owner) {
return (language, source, apply) => {
const kind = source.length === 0 ? '' : language.toLowerCase()
switch (kind) {
case 'latex':
return render(source, true, document.createElement('div'))
case 'mermaid':
return owner.show(source, apply)
default:
return null
}
}
}
/** Milkdown's code block config; read-only in preview. */
export function code(texts, editable, owner) {
const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)]
return (ctx) =>
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
...icons,
extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked],
languages,
searchPlaceholder: texts.searchLanguage,
noResultText: texts.noResult,
copyText: texts.copy,
previewLabel: texts.preview,
previewLoading: texts.loading,
previewToggleButton: (only) => (only ? texts.edit : texts.hide),
renderPreview: previews(owner),
}))
}
+34
View File
@@ -0,0 +1,34 @@
// Mermaid, loaded on first use; only saved source renders.
import mermaid from 'mermaid'
let count = 0
/** The theme from the page's Ely tokens. */
function variables() {
const style = getComputedStyle(document.documentElement)
const token = (name) => style.getPropertyValue(`--ely-${name}`).trim()
return {
background: token('bg'),
primaryColor: token('surface'),
primaryTextColor: token('fg'),
primaryBorderColor: token('border'),
lineColor: token('fg-muted'),
textColor: token('fg'),
fontFamily: token('font'),
}
}
/** `source` as SVG; `paper` draws Mermaid's light theme for export. */
window.elyDiagram = async (source, look) => {
const paper = look === 'paper'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme: paper ? 'default' : 'base',
themeVariables: paper ? {} : variables(),
})
count += 1
const { svg } = await mermaid.render(`ely-diagram-${count}`, source)
return svg
}
+90
View File
@@ -0,0 +1,90 @@
// Mermaid previews; each editor owns its records.
import { failure } from './failure.js'
// Beside this script; the page's base is the document's folder.
const here = document.currentScript.src
/** The Mermaid bundle, added once as a classic script. */
let loading
export function bundle() {
loading ??= new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = new URL('diagram.js', here).href
script.onload = () => resolve(window.elyDiagram)
script.onerror = () => reject(new Error('diagram.js did not load'))
document.head.append(script)
})
return loading
}
let queue = Promise.resolve()
let count = 0
const owners = new Set()
// New tokens mean a new theme; every owner redraws.
new MutationObserver(() => {
for (const owner of owners) owner.redraw()
}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
/** A block's diagram, by its id on the page. */
const box = (id, inner) => `<div class="ely-diagram" data-diagram="${id}">${inner}</div>`
const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null
// After the code block's next render.
const settle = () => new Promise((done) => setTimeout(done))
/** One editor's diagrams; a result lands only on its placeholder. */
export function diagrams() {
const records = new Map()
/** Drops diagrams whose placeholder has left the page. */
const sweep = () => {
for (const [id, record] of records) {
if (record.settled && !placed(id)) records.delete(id)
}
}
/** Each draw has a turn; a later draw voids earlier. */
const draw = (id, record) => {
const turn = ++record.turn
const live = () => records.get(id) === record && record.turn === turn && placed(id)
queue = queue.then(async () => {
await settle()
if (!live()) return
try {
const render = await bundle()
if (!live()) return
const svg = await render(record.source)
await settle()
if (live()) record.apply(box(id, svg))
} catch (error) {
await settle()
if (!live()) return
records.delete(id)
record.apply(failure(record.source, error.message))
}
})
}
const owner = {
/** The placeholder for `source`; its diagram follows. */
show(source, apply) {
sweep()
const id = `${++count}`
const record = { source, apply, settled: false, turn: 0 }
records.set(id, record)
settle().then(() => (record.settled = true))
draw(id, record)
return box(id, '')
},
redraw() {
sweep()
for (const [id, record] of records) draw(id, record)
},
release() {
records.clear()
owners.delete(owner)
},
}
owners.add(owner)
return owner
}
+53
View File
@@ -0,0 +1,53 @@
// Myers' diff over two lists.
/** Equal pairs between `a` and `b`, in order. */
export function common(a, b, same) {
const n = a.length
const m = b.length
const max = n + m
const reach = new Map([[1, 0]])
const trace = []
for (let d = 0; d <= max; d++) {
trace.push(new Map(reach))
for (let k = -d; k <= d; k += 2) {
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1
let y = x - k
while (x < n && y < m && same(a[x], b[y])) {
x += 1
y += 1
}
reach.set(k, x)
if (x >= n && y >= m) return walk(trace, n, m)
}
}
throw new Error('diff did not end')
}
/** Back through the trace: each equal pair, in order. */
function walk(trace, n, m) {
const pairs = []
let x = n
let y = m
for (let d = trace.length - 1; d > 0; d--) {
const reach = trace[d]
const k = x - y
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
const from = down ? k + 1 : k - 1
const fromX = reach.get(from)
const fromY = fromX - from
while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) {
x -= 1
y -= 1
pairs.push([x, y])
}
x = fromX
y = fromY
}
while (x > 0 && y > 0) {
x -= 1
y -= 1
pairs.push([x, y])
}
return pairs.reverse()
}
+72
View File
@@ -0,0 +1,72 @@
/* Exports: KaTeX with its fonts inline, on light paper. */
@import 'katex/dist/katex.min.css';
:root {
color-scheme: light;
}
body {
margin: 0 auto;
max-width: 48rem;
padding: 2rem 1.5rem;
background: #fff;
color: #1f2328;
font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
}
a {
color: #0969da;
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875em;
}
pre {
overflow-x: auto;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #f6f8fa;
}
blockquote {
margin: 0;
padding: 0 1rem;
border-left: 3px solid #d0d7de;
color: #59636e;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.25rem 0.75rem;
border: 1px solid #d0d7de;
}
img,
figure.diagram svg {
max-width: 100%;
}
figure.diagram {
margin: 1rem 0;
text-align: center;
}
@media print {
body {
max-width: none;
padding: 0;
}
pre,
table,
figure {
break-inside: avoid;
}
}
+11
View File
@@ -0,0 +1,11 @@
/** The source as saved, and why it did not render. */
export function failure(source, message, at) {
const box = document.createElement('div')
box.className = 'ely-failure'
const why = document.createElement('p')
why.textContent = at === undefined ? message : `${message} (${at})`
const code = document.createElement('pre')
code.textContent = source
box.append(why, code)
return box
}
+138
View File
@@ -0,0 +1,138 @@
// Find and replace in either editor (MARK-06).
import {
SearchQuery as TextQuery,
findNext as nextText,
findPrevious as previousText,
replaceAll as allText,
replaceNext as oneText,
setSearchQuery,
} from '@codemirror/search'
import {
SearchQuery as DocQuery,
findNext as nextDoc,
findPrev as previousDoc,
replaceAll as allDoc,
replaceNext as oneDoc,
setSearchState,
} from 'prosemirror-search'
import { TextSelection } from '@milkdown/kit/prose/state'
/** Why a pattern fails under the editors' `gmu` flags. */
function broken(spec) {
if (!spec.regexp || !spec.search) return null
try {
new RegExp(spec.search, 'gmu')
return null
} catch (error) {
return error.message
}
}
/** Whether two specs find the same hits. */
export function sameQuery(a, b) {
return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key])
}
/** One character's width at `pos`: two for a surrogate pair. */
function width(doc, pos) {
const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n')
return next.codePointAt(0) > 0xffff ? 2 : 1
}
/** Where the selection stands among `ranges`. */
function standing(ranges, from, to) {
const at = ranges.findIndex((range) => range.from === from && range.to === to)
return { current: at < 0 ? null : at, total: ranges.length }
}
/** Find in Milkdown's view; `prosemirror-search` is installed there. */
export function docFinder(view) {
let spec = { search: '' }
const query = () => new DocQuery(spec)
const ranges = () => {
const found = []
const asked = query()
if (!asked.valid) return found
const { doc } = view.state
for (let at = 0; ;) {
const hit = asked.findNext(view.state, at)
// Mid-pair, a Unicode pattern answers before `at`.
if (!hit || hit.from < at) return found
found.push(hit)
at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to)
}
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection
return standing(ranges(), from, to)
}
const run = (command) => command(view.state, view.dispatch)
return {
seek(next) {
spec = next
const { from } = view.state.selection
const tr = setSearchState(view.state.tr, query())
// From the hit's start: the hit stays while typing.
tr.setSelection(TextSelection.near(tr.doc.resolve(from)))
view.dispatch(tr)
if (query().valid) run(nextDoc)
return status()
},
step(forward) {
if (query().valid) run(forward ? nextDoc : previousDoc)
return status()
},
replace(all) {
if (query().valid) run(all ? allDoc : oneDoc)
return status()
},
clear() {
spec = { search: '' }
view.dispatch(setSearchState(view.state.tr, query()))
},
}
}
/** Find in CodeMirror; its `search()` extension is installed there. */
export function textFinder(view) {
let spec = { search: '' }
const valid = () => new TextQuery(spec).valid
const ranges = () => {
const found = []
const asked = new TextQuery(spec)
if (!asked.valid) return found
const cursor = asked.getCursor(view.state)
for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value)
return found
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection.main
return standing(ranges(), from, to)
}
return {
seek(next) {
spec = next
const selection = { anchor: view.state.selection.main.from }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection })
// CodeMirror opens its own panel for invalid queries.
if (valid()) nextText(view)
return status()
},
step(forward) {
if (valid()) (forward ? nextText : previousText)(view)
return status()
},
replace(all) {
if (valid()) (all ? allText : oneText)(view)
return status()
},
clear() {
spec = { search: '' }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) })
},
}
}
+12
View File
@@ -0,0 +1,12 @@
// Code block icons, drawn in the text color.
const icon = (path) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
export const icons = {
expandIcon: icon('<path d="m6 9 6 6 6-6"/>'),
searchIcon: icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
clearSearchIcon: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
copyIcon: icon(
'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
),
}
+14
View File
@@ -0,0 +1,14 @@
// Untitled images: remark gives null, the schema wants text.
import { imageSchema } from '@milkdown/kit/preset/commonmark'
export const image = imageSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
parseMarkdown: {
match: base.parseMarkdown.match,
runner: (state, node, type) =>
state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }),
},
}
})
+22
View File
@@ -0,0 +1,22 @@
// The writing view: focus, typewriter, width, font, size (MARK-09).
let typewriter = false
/** Applies `look`; the Markdown never changes. */
export function apply(look) {
const root = document.documentElement
root.dataset.focus = look.focus
root.dataset.width = look.width
root.dataset.font = look.font
root.dataset.typewriter = look.typewriter
typewriter = look.typewriter
const sheet =
document.getElementById('ely-look') ??
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' }))
sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }`
}
/** Typewriter scrolling: the caret's line stays mid-view. */
export function follow(editor) {
if (typewriter) editor?.center?.()
}
+320
View File
@@ -0,0 +1,320 @@
@import 'katex/dist/katex.min.css';
@import '@milkdown/kit/prose/view/style/prosemirror.css';
html,
body {
margin: 0;
height: 100%;
background: var(--ely-bg);
color: var(--ely-fg);
font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font),
sans-serif;
}
::selection {
background: var(--ely-selection);
}
a {
color: var(--ely-link);
}
#document {
box-sizing: border-box;
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1.5rem;
}
:root[data-width='narrow'] #document {
max-width: 36rem;
}
:root[data-width='wide'] #document {
max-width: 64rem;
}
:root[data-font='serif'] #document:not([data-mode='source']) {
font-family: ui-serif, Georgia, serif;
}
:root[data-font='mono'] #document:not([data-mode='source']) {
font-family: var(--ely-mono), monospace;
}
/* Typewriter: room to center the first and last lines. */
:root[data-typewriter='true'] #document:not([data-mode='source']) {
padding-block: 50vh;
}
:root[data-typewriter='true'] .cm-content {
padding-block: 50vh;
}
/* Focus mode: the caret's block, or line, stays bright. */
:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current),
:root[data-focus='true'] .cm-line:not(.cm-activeLine) {
opacity: 0.35;
}
@media (prefers-reduced-motion: no-preference) {
.milkdown .ProseMirror > *,
.cm-line {
transition: opacity 150ms;
}
}
#document[data-mode='source'] {
max-width: none;
height: 100%;
padding: 0;
}
#document[data-mode='source'] > div,
#document[data-mode='source'] .cm-editor {
height: 100%;
}
.milkdown .ProseMirror {
outline: none;
}
.milkdown blockquote {
margin: 0;
padding-left: 1rem;
border-left: 3px solid var(--ely-border);
color: var(--ely-fg-muted);
}
.milkdown code {
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
background: var(--ely-sunken);
border-radius: 4px;
padding: 0 0.25em;
}
.milkdown table {
border-collapse: collapse;
}
.milkdown th,
.milkdown td {
border: 1px solid var(--ely-border);
padding: 0.25rem 0.75rem;
}
.milkdown li > p,
.milkdown th > p,
.milkdown td > p {
margin: 0.25em 0;
}
.milkdown li[data-item-type='task'] {
display: flex;
gap: 0.5em;
list-style: none;
}
.milkdown li[data-item-type='task']::before {
content: '☐ ';
}
.milkdown li[data-item-type='task'][data-checked='true']::before {
content: '☑ ';
}
.milkdown hr {
border: none;
border-top: 1px solid var(--ely-border);
}
.milkdown img {
max-width: 100%;
}
.milkdown-code-block {
display: block;
position: relative;
margin: 0.5rem 0;
padding: 0.5rem 1rem 1rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block.selected {
outline: 1px solid var(--ely-focus);
}
.milkdown-code-block .hidden {
display: none !important;
}
.milkdown-code-block .tools {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
.milkdown-code-block button,
.milkdown-code-block .language-button {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
background: var(--ely-sunken);
color: var(--ely-fg-muted);
font: inherit;
cursor: pointer;
}
[data-mode='preview'] .milkdown-code-block .language-button {
pointer-events: none;
}
.milkdown-code-block .tools-button-group {
display: flex;
gap: 4px;
}
.milkdown-code-block button:hover,
.milkdown-code-block .language-button:hover {
background: var(--ely-hover);
}
.milkdown-code-block .tools svg,
.milkdown-code-block .language-picker svg {
width: 14px;
height: 14px;
}
.ely-diagram svg {
max-width: 100%;
height: auto;
}
.milkdown-code-block .language-picker {
position: absolute;
z-index: 1;
padding-top: 4px;
}
.milkdown-code-block .list-wrapper {
width: 15rem;
padding-top: 0.5rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block .search-box {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0.5rem 0.5rem;
padding: 4px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
}
.milkdown-code-block .search-box input {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--ely-fg);
font: inherit;
}
.milkdown-code-block .search-icon {
display: none;
}
.milkdown-code-block .language-list {
max-height: 20rem;
overflow-y: auto;
margin: 0;
padding: 0;
}
.milkdown-code-block .language-list-item {
display: flex;
gap: 8px;
padding: 2px 1rem;
cursor: pointer;
}
.milkdown-code-block .language-list-item:hover,
.milkdown-code-block .language-list-item:focus-visible {
outline: none;
background: var(--ely-hover);
}
.milkdown-code-block .language-list-item.no-result {
cursor: default;
color: var(--ely-fg-subtle);
}
.milkdown-code-block .preview-panel .preview-divider {
height: 1px;
margin: 0.5rem 0;
background: var(--ely-border);
}
.milkdown-code-block .preview-panel .preview-label {
margin: 0.5rem 0;
font-size: 0.75rem;
color: var(--ely-fg-muted);
}
.milkdown-code-block .preview {
overflow-x: auto;
text-align: center;
}
.ely-failure {
text-align: left;
color: var(--ely-danger);
}
.ely-failure pre {
margin: 0;
white-space: pre-wrap;
color: var(--ely-fg);
font-family: var(--ely-mono), monospace;
}
.ely-front {
margin: 0 0 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--ely-border);
border-radius: 6px;
color: var(--ely-fg-muted);
font-family: var(--ely-mono), monospace;
white-space: pre-wrap;
}
.ely-raw {
margin: 0.5rem 0;
padding: 0.25rem 0.5rem;
background: var(--ely-sunken);
border-radius: 4px;
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
white-space: pre-wrap;
}
.ProseMirror-search-match,
.cm-searchMatch {
background: var(--ely-selection);
}
.ProseMirror-active-search-match,
.cm-searchMatch-selected {
outline: 1px solid var(--ely-focus);
}
+305
View File
@@ -0,0 +1,305 @@
// The page the app drives: one document, three modes.
import { visual } from './visual.js'
import { source } from './source.js'
import { ask } from './bridge.js'
import { sameQuery } from './find.js'
import { apply, follow } from './look.js'
import { html, text } from './render.js'
let texts = {}
let latest = 0
let version = 0
// The app's count of page loads; reports carry it.
let generation = 0
let timer
let pending = null
/** The editor shown. */
let current = null
// A mode asked for while an editor builds.
let asked = null
// A place to show once the editor is built.
let revealing = null
// The search in force, kept across modes.
let seeking = null
/** The file's texts where a mode with edits ended (§14.3). */
let times = []
let at = 0
/** YAML front matter, kept out of the visual editor. */
function split(text) {
const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text)
return front ? [front[0], text.slice(front[0].length)] : ['', text]
}
const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`))
/** Sends the waiting text now. */
function flush() {
clearTimeout(timer)
if (pending) {
tell(pending())
outline()
}
pending = null
}
/** The shown editor's headings, for the app's outline. */
function outline() {
if (current) tell({ kind: 'outline', generation, items: current.outline() })
}
/** A new text: told now, its words once typing rests. */
function revised(text) {
version += 1
const now = version
tell({ kind: 'changed', version: now, generation })
clearTimeout(timer)
pending = () => ({ kind: 'text', version: now, generation, text: text() })
timer = setTimeout(flush, 300)
}
/** An edit: later texts in the timeline are gone. */
function changed(text) {
times.length = at + 1
revised(text)
}
/** An editor for `mode`; visual falls back to source. */
async function build(text, mode) {
const holder = document.createElement('div')
holder.hidden = true
document.getElementById('document').append(holder)
const entry = { mode, holder, fallback: false }
if (mode !== 'source') {
const [front, body] = split(text)
if (front) {
const raw = document.createElement('pre')
raw.className = 'ely-front'
raw.textContent = front
holder.append(raw)
}
const place = document.createElement('div')
holder.append(place)
const edited = (write) => changed(() => front + write())
const made = await visual(place, body, mode === 'visual', texts, edited, edge)
if (made) {
// Lines count from the file's top, front matter included.
const above = front ? front.split('\n').length - 1 : 0
const line = (n, query) => {
if (n > above) return made.line(n - above, query)
holder.firstChild.scrollIntoView({ block: 'center' })
return true
}
const redone = () => {
const ahead = made.redone()
return ahead === null ? null : front + ahead
}
const where = () => {
const { line, column } = made.where()
return { line: above + line, column }
}
const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, ''))
return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at }
}
// Blocks the editor cannot map open as source.
holder.replaceChildren()
Object.assign(entry, { mode: 'source', fallback: true })
}
return { ...entry, ...source(holder, text, changed, edge) }
}
function drop(entry) {
entry.close()
entry.holder.remove()
}
/** Shows `text` as `want`; the newest request alone lands. */
async function show(text, want) {
const turn = ++latest
asked = null
if (current) drop(current)
current = null
const entry = await build(text, want)
if (turn !== latest) return drop(entry)
current = entry
entry.holder.hidden = false
document.getElementById('document').dataset.mode = entry.mode
entry.focus()
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback })
// A newer request owns what is queued now.
if (turn !== latest) return
outline()
if (seeking) found(entry.find.seek(seeking))
const place = revealing
revealing = null
if (place) reveal(place)
const next = asked
asked = null
if (next && next !== entry.mode) return mode(next)
}
/** Undo or redo past an editor's own history. */
function edge(direction) {
const to = direction === 'undo' ? at - 1 : at + 1
if (!current || to < 0 || to >= times.length) return false
flush()
at = to
const text = times[at]
revised(() => text)
show(text, current.mode)
return true
}
/** Relative links and images resolve under `href`. */
function base(href) {
const link =
document.querySelector('base') ?? document.head.appendChild(document.createElement('base'))
link.href = href
}
/** Puts the caret on `place.line`, the page at `place.scroll`. */
function settle(place) {
if (!current || !place) return
current.at(place.line, place.column)
document.scrollingElement.scrollTop = place.scroll
}
/** A document, after each page load; `place` restores a session. */
async function open(text, want, words, load, folder, place) {
texts = words
generation = load
times = [text]
at = 0
base(folder)
await show(text, want)
settle(place)
}
/** Changed on disk: the caret and scroll stay near. */
async function refresh(text) {
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
clearTimeout(timer)
pending = null
times = [text]
at = 0
await show(text, current?.mode ?? 'visual')
settle(place)
}
/** `text` in place of the editor's, as one edit. */
function adopt(text) {
if (current) flush()
times.length = at + 1
times.push(text)
at += 1
revised(() => text)
return show(text, current?.mode ?? 'visual')
}
let placing
/** The caret's line and the scroll, once they rest. */
function placed() {
clearTimeout(placing)
placing = setTimeout(() => {
if (!current) return
const scroll = document.scrollingElement.scrollTop
tell({ kind: 'place', generation, ...current.where(), scroll })
}, 500)
}
document.addEventListener('selectionchange', () => {
placed()
follow(current)
})
window.addEventListener('scroll', placed, { passive: true })
/** Another mode for the same text; edits are sent first. */
function mode(next) {
if (!current) {
asked = next
return
}
const text = current.text()
const ahead = current.redone?.() ?? null
flush()
// Leaving a mode with edits marks the timeline.
if (text !== times[at]) {
times.length = at + 1
times.push(text)
at += 1
}
// Undone steps stay ahead, for redo.
if (ahead !== null && ahead !== text) {
times.length = at + 1
times.push(ahead)
}
return show(text, next)
}
/** Shows `{ heading }` or `{ line, query }`. */
function reveal(place) {
if (!current) {
revealing = place
return
}
const shown =
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`)
}
/** The app's find box hears where the search stands. */
const found = (status) => tell({ kind: 'found', generation, ...status })
/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */
function find(spec) {
seeking = spec
if (current) found(current.find.seek(spec))
}
/** The box's spec rules; a new query starts afresh. */
function step({ spec, forward }) {
const fresh = !seeking || !sameQuery(seeking, spec)
find(spec)
if (current && !fresh) found(current.find.step(forward))
}
function replace({ spec, all }) {
find(spec)
if (current && current.mode !== 'preview') found(current.find.replace(all))
}
function unfind() {
seeking = null
current?.find.clear()
}
/** The writing view's settings (MARK-09). */
function look(settings) {
apply(settings)
follow(current)
}
/** The whole text as `kind`: html or text, tagged `ask`. */
async function render({ kind, ask: asked }) {
try {
if (!current) throw new Error('no document is shown')
const [, body] = split(current.text())
const result = kind === 'text' ? { text: await text(body) } : await html(body)
tell({ kind: 'rendered', generation, ask: asked, ...result })
} catch (error) {
tell({ kind: 'rendered', generation, ask: asked, error: error.message })
}
}
/** A table step; the app hears whether it ran. */
function table(op) {
const done = current?.table?.(op) ?? false
tell({ kind: 'table', generation, op, done })
}
/** Image links at the caret; preview takes none. */
function insert(images) {
const done = current?.insert?.(images) ?? false
tell({ kind: 'inserted', generation, done })
}
window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert }
+100
View File
@@ -0,0 +1,100 @@
// KaTeX math, from Milkdown Crepe's latex feature.
import katex from 'katex'
import remarkMath from 'remark-math'
import { visit } from 'unist-util-visit'
import { $nodeSchema, $remark } from '@milkdown/kit/utils'
import { codeBlockSchema } from '@milkdown/kit/preset/commonmark'
import { failure } from './failure.js'
/** Saved source as HTML; a bad one shows where. */
export function render(source, display, dom) {
try {
katex.render(source, dom, { displayMode: display, throwOnError: true })
} catch (error) {
const at = error instanceof katex.ParseError ? error.position : undefined
dom.replaceChildren(failure(source, error.message, at))
}
return dom
}
// remark-math, plus each inline formula's `$` count from the source.
const remark = $remark('remarkMath', () => function (options) {
remarkMath.call(this, options)
return (tree, file) => {
const text = String(file.value)
visit(tree, 'inlineMath', (node) => {
let at = node.position?.start.offset ?? 0
let fence = 0
while (text[at++] === '$') fence++
node.data = { ...node.data, fence: Math.max(fence, 1) }
})
}
})
// Block math becomes a code block in language LaTeX.
const blocks = $remark(
'remarkMathBlock',
() => () => (tree) =>
visit(tree, 'math', (node, index, parent) => {
parent.children.splice(index, 1, {
type: 'code',
lang: 'LaTeX',
value: node.value,
position: node.position,
})
}),
)
const block = codeBlockSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
toMarkdown: {
match: base.toMarkdown.match,
runner: (state, node) => {
if ((node.attrs.language ?? '').toLowerCase() !== 'latex') {
return base.toMarkdown.runner(state, node)
}
state.addNode('math', undefined, node.content.firstChild?.text || '')
},
},
}
})
const inline = $nodeSchema('math_inline', () => ({
group: 'inline',
inline: true,
atom: true,
attrs: { value: { default: '' }, fence: { default: 1 } },
parseDOM: [
{
tag: 'span[data-type="math_inline"]',
getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }),
},
],
toDOM: (node) => {
const dom = document.createElement('span')
dom.dataset.type = 'math_inline'
dom.dataset.value = node.attrs.value
dom.dataset.fence = node.attrs.fence
// `$$…$$` displays, as GitHub shows it.
return render(node.attrs.value, node.attrs.fence > 1, dom)
},
parseMarkdown: {
match: (node) => node.type === 'inlineMath',
runner: (state, node, type) =>
state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }),
},
toMarkdown: {
match: (node) => node.type.name === 'math_inline',
runner: (state, node) => {
const { value, fence } = node.attrs
if (fence === 1) return state.addNode('inlineMath', undefined, value)
// The stringifier knows one `$`; the source's own count stays.
const dollars = '$'.repeat(fence)
state.addNode('html', undefined, `${dollars}${value}${dollars}`)
},
},
}))
export const math = [remark, blocks, block, inline].flat()
+144
View File
@@ -0,0 +1,144 @@
// Blocks Milkdown cannot write back stay raw text (§14.3).
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
import { Fragment } from '@milkdown/kit/prose/model'
import { Selection } from '@milkdown/kit/prose/state'
import { $nodeSchema } from '@milkdown/kit/utils'
import { common } from './diff.js'
/** Source shown and written as it is. */
export const raw = $nodeSchema('ely_raw', () => ({
group: 'block',
content: 'text*',
marks: '',
code: true,
defining: true,
isolating: true,
parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }],
toDOM: () => ['pre', { class: 'ely-raw' }, 0],
parseMarkdown: { match: () => false, runner: () => {} },
toMarkdown: {
match: (node) => node.type.name === 'ely_raw',
runner: (state, node) => state.addNode('html', undefined, node.textContent),
},
}))
/** Each block's span, as Milkdown reads it; null if unplaced. */
function spans(ctx, text) {
const remark = ctx.get(remarkCtx)
const tree = remark.runSync(remark.parse(text), text)
const at = tree.children.map(
(node) => node.position && [node.position.start.offset, node.position.end.offset],
)
return at.every(Boolean) ? at : null
}
/** The text in `from`..`to` past its outer whitespace. */
function inner(text, from, to) {
const piece = text.slice(from, to)
const start = piece.search(/\S/)
if (start < 0) return null
return [from + start, from + piece.trimEnd().length]
}
/** Raw blocks in place; edits then rewrite only their blocks. */
export function faithful(ctx, original) {
const view = ctx.get(editorViewCtx)
const parsed = view.state.doc
const at = spans(ctx, original)
// No blocks: Milkdown still holds one empty paragraph.
const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === ''
if (!at || (at.length !== parsed.childCount && !blank)) return null
const schema = ctx.get(schemaCtx)
const serializer = ctx.get(serializerCtx)
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
const rawOf = ([from, to]) => {
const text = original.slice(from, to)
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
}
const nodes = []
const places = []
// Text Milkdown drops between blocks, as raw blocks.
const gap = (from, to) => {
const place = inner(original, from, to)
if (!place) return
nodes.push(rawOf(place))
places.push(place)
}
let last = 0
if (!blank)
parsed.forEach((node, _, index) => {
gap(last, at[index][0])
const exact = written([node]) === original.slice(...at[index])
nodes.push(exact ? node : rawOf(at[index]))
places.push(at[index])
last = at[index][1]
})
gap(last, original.length)
if (nodes.length) {
const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes))
swap.setSelection(Selection.atStart(swap.doc))
view.dispatch(swap.setMeta('addToHistory', false))
}
const first = view.state.doc
const before = []
first.forEach((node) => before.push(node))
/** Where each fresh block sits in `text` from `at`. */
const found = (nodes, text, at, spans) => {
let from = 0
for (const node of nodes) {
const own = written([node])
const place = text.indexOf(own, from)
spans.push(place < 0 ? null : [at + place, at + place + own.length])
if (place >= 0) from = place + own.length
}
}
// The text and each block's span in it.
return (doc) => {
if (doc.eq(first)) return { text: original, spans: places }
const after = []
doc.forEach((node) => after.push(node))
if (!places.length) {
const fresh = written(after)
const spans = []
found(after, fresh, original.length, spans)
return { text: `${original}${fresh}\n`, spans }
}
const pairs = common(before, after, (x, y) => x === y || x.eq(y))
pairs.push([before.length, after.length])
let out = ''
let cursor = 0
let [i, j] = [0, 0]
const spans = []
for (const [x, y] of pairs) {
if (x > i || y > j) {
const fresh = y > j ? written(after.slice(j, y)) : ''
let start, end, text
if (x > i && fresh) {
;[start, end, text] = [places[i][0], places[x - 1][1], fresh]
} else if (x > i && i > 0) {
// Removed blocks take the gap before them.
;[start, end, text] = [places[i - 1][1], places[x - 1][1], '']
} else if (x > i && x < places.length) {
;[start, end, text] = [places[0][0], places[x][0], '']
} else if (x > i) {
;[start, end, text] = [places[0][0], places[x - 1][1], '']
} else if (i > 0) {
;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`]
} else {
;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`]
}
out += original.slice(cursor, start)
found(after.slice(j, y), text, out.length, spans)
out += text
cursor = end
}
// Unchanged blocks keep their bytes, shifted.
if (x < places.length) {
const shift = out.length - cursor
spans.push([places[x][0] + shift, places[x][1] + shift])
}
;[i, j] = [x + 1, y + 1]
}
return { text: out + original.slice(cursor), spans }
}
}
+110
View File
@@ -0,0 +1,110 @@
// The whole document as one page or as text (MARK-11).
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import { fromDom } from 'hast-util-from-dom'
import rehypeStringify from 'rehype-stringify'
import { toText } from 'hast-util-to-text'
import { visit } from 'unist-util-visit'
import { VFile } from 'vfile'
import { bundle } from './diagrams.js'
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
/** Mermaid drawn for paper; a broken one stays code. */
async function draw(tree) {
const blocks = []
visit(tree, 'code', (node) => {
if (node.lang === 'mermaid') blocks.push(node)
})
const failed = []
if (!blocks.length) return failed
const paint = await bundle()
for (const node of blocks) {
try {
const svg = await paint(node.value, 'paper')
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
} catch (error) {
// Lines only: Mermaid's message quotes the document.
console.warn(`ely: a diagram did not draw: ${error.name}`)
failed.push(node.position.start.line)
}
}
return failed
}
/** A `srcset`'s first URL, split as the spec does. */
function first(srcset) {
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
return url.replace(/,+$/, '')
}
/** One `src` per image, swapped for the app's mark. */
function marked(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
const token = `ely-image-${crypto.randomUUID()}-`
// Responsive sources go: the page carries one file per image.
for (const source of doc.querySelectorAll('picture > source')) source.remove()
const images = []
for (const image of doc.querySelectorAll('img')) {
const srcset = image.getAttribute('srcset')
image.removeAttribute('srcset')
image.removeAttribute('sizes')
const src = image.getAttribute('src') || (srcset && first(srcset))
if (!src) continue
images.push(src)
image.setAttribute('src', token + (images.length - 1))
}
return { html: doc.body.innerHTML, images, token }
}
/** KaTeX's failures: line and its message, never logged. */
function formulas(file) {
return file.messages
.filter((message) => message.source === 'rehype-katex')
.map((message) => ({
line: message.line,
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
}))
}
/** `body` as HTML, images marked; the failed diagrams and formulas. */
export async function html(body) {
const tree = markdown().parse(body)
displayed(tree, body)
const failed = await draw(tree)
const page = unified()
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
.use(rehypeStringify, { allowDangerousHtml: true })
const file = new VFile()
const done = page.stringify(await page.run(tree, file), file)
return { ...marked(done), failed, formulas: formulas(file) }
}
/** `$$…$$` inline displays, as in the editor. */
function displayed(tree, body) {
visit(tree, 'inlineMath', (node) => {
const at = node.position?.start.offset ?? 0
if (body.startsWith('$$', at)) {
node.data.hProperties = { className: ['language-math', 'math-display'] }
}
})
}
/** `body` as plain text: blocks on lines, math as TeX. */
export async function text(body) {
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
const hast = await page.run(markdown().parse(body))
// Raw HTML alone goes through the DOM; blocks keep lines.
visit(hast, 'raw', (node, index, parent) => {
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
const shown = fromDom(parsed).children
parent.children.splice(index, 1, ...shown)
return index + shown.length
})
return toText(hast)
}
+109
View File
@@ -0,0 +1,109 @@
// CodeMirror 6: the Markdown source.
import { basicSetup, EditorView } from 'codemirror'
import { Prec } from '@codemirror/state'
import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands'
import { keymap } from '@codemirror/view'
import { markdown as language } from '@codemirror/lang-markdown'
import { languages } from '@codemirror/language-data'
import { ensureSyntaxTree } from '@codemirror/language'
import { theme } from './theme.js'
import { search } from '@codemirror/search'
import { textFinder } from './find.js'
/** Alt text that cannot close its brackets. */
const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&')
export function source(root, markdown, changed, edge) {
const view = new EditorView({
doc: markdown,
extensions: [
basicSetup,
language({ codeLanguages: languages }),
EditorView.lineWrapping,
theme,
search(),
// Tab indents; it never leaves the editor.
keymap.of([indentWithTab]),
// Past their history, undo and redo reach the page.
Prec.low(
keymap.of([
{ key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') },
{ key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
{ key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
]),
),
EditorView.updateListener.of((update) => {
if (update.docChanged) changed(() => update.state.doc.toString())
}),
],
parent: root,
})
// ATX and setext headings from the parse, with their starts.
const headings = () => {
const found = []
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200)
tree?.iterate({
enter: ({ name, from, to }) => {
const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1]
if (!level) return
const head = view.state.doc.sliceString(from, to).split('\n')[0]
const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '')
found.push({ level: Number(level), title: title.trim(), from })
return false
},
})
return found
}
const reveal = (from) => {
view.dispatch({ selection: { anchor: from }, scrollIntoView: true })
view.focus()
}
return {
// Images at the caret, as Markdown.
insert: (images) => {
const text = images.map(({ src, alt }) => `![${escape(alt)}](${src})`).join('\n')
view.dispatch(view.state.replaceSelection(text))
view.focus()
return true
},
find: textFinder(view),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.from)
return Boolean(found)
},
line: (n, query) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
const column = line.text.toLowerCase().indexOf(query.toLowerCase())
reveal(line.from + Math.max(0, column))
return true
},
close: () => view.destroy(),
center: () => {
const { head } = view.state.selection.main
view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) })
},
where: () => {
const { head } = view.state.selection.main
const line = view.state.doc.lineAt(head)
return { line: line.number, column: head - line.from }
},
at: (n, column) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
reveal(line.from + Math.min(column, line.length))
return true
},
text: () => view.state.doc.toString(),
// The text once every undone step is redone.
redone: () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) })
return state.doc.toString()
},
focus: () => view.focus(),
}
}
+41
View File
@@ -0,0 +1,41 @@
// The document's own markers, so blocks write back unchanged.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import { visit } from 'unist-util-visit'
/** remark-stringify options most of `text` already uses. */
export function style(text) {
const votes = {}
const vote = (key, value) => {
votes[key] ??= new Map()
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
}
visit(unified().use(remarkParse).parse(text), (node) => {
const at = node.position?.start.offset
if (at === undefined) return
const head = text.slice(at, at + 12)
switch (node.type) {
case 'listItem': {
const ordered = /^\d+([.)])/.exec(head)
if (ordered) vote('bulletOrdered', ordered[1])
else if (/^[*+-]/.test(head)) vote('bullet', head[0])
break
}
case 'emphasis':
case 'strong':
vote(node.type, head[0])
break
case 'code':
if (/^[`~]/.test(head)) vote('fence', head[0])
break
case 'thematicBreak':
vote('rule', head[0])
break
case 'heading':
if (node.depth <= 2) vote('setext', head[0] !== '#')
break
}
})
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)]))
}
+42
View File
@@ -0,0 +1,42 @@
// CodeMirror colors from the page's Ely tokens.
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags } from '@lezer/highlight'
const token = (name) => `var(--ely-${name})`
const colors = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') },
{ tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') },
{ tag: [tags.comment, tags.meta], color: token('comment') },
{
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
color: token('function'),
},
{ tag: [tags.typeName, tags.className, tags.namespace], color: token('type') },
{ tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' },
{ tag: [tags.attributeName, tags.propertyName], color: token('attribute') },
{
tag: [tags.punctuation, tags.bracket, tags.processingInstruction],
color: token('punctuation'),
},
{ tag: [tags.link, tags.url], color: token('link') },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
])
const frame = EditorView.theme({
'&': { color: token('fg'), backgroundColor: 'transparent' },
'.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' },
'.cm-cursor': { borderLeftColor: token('fg') },
'.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' },
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: token('selection'),
},
'&.cm-focused': { outline: 'none' },
})
export const theme = [frame, syntaxHighlighting(colors)]
+313
View File
@@ -0,0 +1,313 @@
// Milkdown: visual editing, or read-only preview.
import {
Editor,
defaultValueCtx,
remarkStringifyOptionsCtx,
editorViewCtx,
editorViewOptionsCtx,
parserCtx,
remarkCtx,
rootCtx,
} from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { gfm } from '@milkdown/kit/preset/gfm'
import { history } from '@milkdown/kit/plugin/history'
import { codeBlockComponent } from '@milkdown/kit/component/code-block'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { keymap } from '@milkdown/kit/prose/keymap'
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
import { $prose, callCommand } from '@milkdown/kit/utils'
import { insertTableCommand } from '@milkdown/kit/preset/gfm'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
deleteColumn,
deleteRow,
} from '@milkdown/kit/prose/tables'
import { visit } from 'unist-util-visit'
import { image } from './image.js'
import { math } from './math.js'
import { code } from './code.js'
import { diagrams } from './diagrams.js'
import { faithful, raw } from './preserve.js'
import { docFinder } from './find.js'
import { search } from 'prosemirror-search'
import { style } from './style.js'
/** An editor over `markdown`; null when blocks cannot map. */
export async function visual(root, markdown, editable, texts, changed, edge) {
const owner = diagrams()
let write = () => ({ text: markdown, spans: [] })
// Setting up raw blocks is no edit.
let ready = false
const watch = $prose(
() =>
new Plugin({
view: () => ({
update: (view, before) => {
if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text)
},
}),
}),
)
// Focus mode dims all blocks but the caret's.
const current = $prose(
() =>
new Plugin({
props: {
decorations: (state) => {
const { $from } = state.selection
if ($from.depth < 1) return null
const start = $from.before(1)
const block = Decoration.node(start, start + $from.node(1).nodeSize, {
class: 'ely-current',
})
return DecorationSet.create(state.doc, [block])
},
},
}),
)
// Past their history, undo and redo reach the page.
const ends = $prose(() =>
keymap({
'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'),
'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'),
'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'),
// Tab moves list items; it never leaves the editor.
Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true,
'Shift-Tab': (state, dispatch) =>
liftListItem(state.schema.nodes.list_item)(state, dispatch) || true,
}),
)
const made = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) }))
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
})
.use(commonmark)
.use(gfm)
.use(history)
.use(image)
.use(math)
.use(raw)
.config(code(texts, editable, owner))
.use(codeBlockComponent)
.use(watch)
.use(current)
.use(ends)
.use($prose(() => search()))
.create()
const editor = await made.catch((error) => {
owner.release()
throw error
})
const view = editor.action((ctx) => ctx.get(editorViewCtx))
const close = () => {
editor.destroy()
owner.release()
}
const keep = editor.action((ctx) => faithful(ctx, markdown))
if (!keep) {
close()
return null
}
write = keep
ready = true
// The text once every undone step is redone.
const redone = () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr)))
return write(state.doc).text
}
// Headings, raw ones too, by the title their source gives.
const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)])
/** An mdast node's words. */
const words = (node) => node.value ?? (node.children ?? []).map(words).join('')
// Headings; inside raw blocks, as remark reads their source.
const headings = () => {
const found = []
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading')
found.push({ level: node.attrs.level, title: node.textContent, pos })
if (node.type.name !== 'ely_raw') return true
visit(remark.parse(node.textContent), 'heading', (heading) => {
const start = (heading.children[0] ?? heading).position.start.offset
found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start })
})
return false
})
return found
}
// `exact`: the caret at `pos`; else in that block.
const reveal = (pos, exact) => {
if (editable) {
const doc = view.state.doc
const place = exact
? TextSelection.create(doc, pos)
: TextSelection.near(doc.resolve(pos + 1))
view.dispatch(view.state.tr.setSelection(place).scrollIntoView())
return view.focus()
}
const { node } = view.domAtPos(pos + 1)
;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' })
}
/** `node`'s text, and where a character of it sits. */
const reading = (node, start) => {
const chunks = []
// Text and line breaks, as `textContent` reads them.
node.descendants((child, at) => {
const words = child.isText
? child.text
: child.isLeaf
? child.type.spec.leafText?.(child)
: null
if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at })
})
const at = (offset) => {
for (const [index, chunk] of chunks.entries()) {
const last = index === chunks.length - 1
if (offset < chunk.text.length || (last && offset === chunk.text.length))
return chunk.at + offset
offset -= chunk.text.length
}
return null
}
return { joined: chunks.map((chunk) => chunk.text).join(''), at }
}
// Non-overlapping hits, the same rule as `split`.
const hits = (text, needle) => text.split(needle).length - 1
/** The block holding file line `n`: its index and start. */
const blockOf = (n) => {
const { text, spans } = write(view.state.doc)
let offset = 0
for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1
if (n < 1 || offset < 0) return null
const index = spans.findIndex((span) => span && span[1] >= offset)
if (index < 0) return null
let pos = 0
for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize
return { text, spans, offset, index, pos }
}
/** Line `n`: the query's place in its block. */
const line = (n, query) => {
const block = blockOf(n)
if (!block) return false
const { text, spans, offset, index, pos } = block
const needle = query.toLowerCase()
const node = view.state.doc.child(index)
// Text before and through the line, as shown.
const shown = (end) => {
const source = text.slice(spans[index][0], Math.max(spans[index][0], end))
return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase()
}
const ends = text.indexOf('\n', offset)
const before = shown(offset)
const through = shown(ends < 0 ? text.length : ends)
const { joined, at } = reading(node, pos)
const k = needle ? hits(before, needle) : 0
let found = -1
if (needle && hits(through, needle) > k) {
let from = 0
for (let seen = 0; seen <= k; seen++) {
found = joined.indexOf(needle, from)
if (found < 0) break
from = found + needle.length
}
}
// A hit hidden, as in a link: the line's start.
const place = found >= 0 ? at(found) : at(before.length)
reveal(place ?? pos, place !== null)
return true
}
/** The caret: its block's file line, and how far in. */
const where = () => {
const { text, spans } = write(view.state.doc)
const { from } = view.state.selection
let [start, pos] = [0, 0]
view.state.doc.forEach((node, offset, index) => {
if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset]
})
return { line: text.slice(0, start).split('\n').length, column: from - pos }
}
/** The caret back where `where` read it. */
const at = (n, column) => {
const block = blockOf(n)
if (!block) return false
const size = view.state.doc.child(block.index).nodeSize
reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false)
return true
}
// Rows and columns; false when the caret is outside tables.
const tables = {
'row-above': addRowBefore,
'row-below': addRowAfter,
'column-left': addColumnBefore,
'column-right': addColumnAfter,
'delete-row': deleteRow,
'delete-column': deleteColumn,
}
const tablesIn = (doc) => {
const found = []
doc.descendants((node, pos) => {
if (node.type.name === 'table') found.push({ node, pos })
})
return found
}
// Milkdown may leave the caret before the new table.
const newTable = () => {
const old = new Set(tablesIn(view.state.doc).map(({ node }) => node))
editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))
const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node))
if (!made) throw new Error('the inserted table is missing')
const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1))
view.dispatch(view.state.tr.setSelection(cell).scrollIntoView())
return true
}
const table = (op) => {
if (!editable) return false
const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch)
view.focus()
return done
}
/** Images at the caret: `{ src, alt }` each. */
const insert = (images) => {
if (!editable) return false
const image = view.state.schema.nodes.image
let tr = view.state.tr
for (const { src, alt } of images)
tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false)
view.dispatch(tr.scrollIntoView())
view.focus()
return true
}
return {
insert,
table,
find: docFinder(view),
close,
text: () => write(view.state.doc).text,
redone,
focus: () => editable && view.focus(),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.at ?? found.pos, found.at !== undefined)
return Boolean(found)
},
line,
where,
at,
// The caret's line to the middle of the view.
center: () => {
const { top } = view.coordsAtPos(view.state.selection.head)
window.scrollBy(0, top - window.innerHeight / 2)
},
}
}