Remove CEF: the web content crate, ElyMark's page, their packaging, licences and tests (C14.5)
PRD and agents.md name the native route; ElyMail's body will not use CEF or Electron. ElyMark's package carries the KaTeX fonts' OFL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2e8379b0b1
commit
8a942e6d80
77 files changed
+105
-10718
No files matched your search
@@ -1,89 +0,0 @@
|
||||
// Builds the page into the folder given; no network.
|
||||
import { build } from 'esbuild'
|
||||
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = process.argv[2]
|
||||
if (!out) throw new Error('usage: node build.mjs <output folder>')
|
||||
await rm(out, { recursive: true, force: true })
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const built = await build({
|
||||
entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
target: 'chrome140',
|
||||
loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' },
|
||||
assetNames: 'fonts/[name]-[hash]',
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
// Exports carry one stylesheet: fonts inline, woff2 alone.
|
||||
const exported = await build({
|
||||
entryPoints: ['src/export.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' },
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
const sheet = join(out, 'export.css')
|
||||
// The dropped woff and ttf left empty sources.
|
||||
await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, ''))
|
||||
await copyFile('index.html', join(out, 'index.html'))
|
||||
|
||||
/** Each bundled package's folder under node_modules. */
|
||||
function packages(inputs) {
|
||||
const found = new Set()
|
||||
for (const input of inputs) {
|
||||
const at = input.lastIndexOf('node_modules/')
|
||||
if (at < 0) continue
|
||||
const parts = input.slice(at + 13).split('/')
|
||||
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
|
||||
found.add(input.slice(0, at + 13) + name)
|
||||
}
|
||||
return [...found].sort()
|
||||
}
|
||||
|
||||
/** Name, version and license; the file, else the manifest's. */
|
||||
async function notice(folder) {
|
||||
const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8'))
|
||||
const head = `${manifest.name} ${manifest.version} (${manifest.license})`
|
||||
const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name))
|
||||
if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n`
|
||||
const author = manifest.author?.name ?? manifest.author
|
||||
if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`)
|
||||
console.log(`${manifest.name} ships no license file; MIT from its manifest`)
|
||||
return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n`
|
||||
}
|
||||
|
||||
const MIT = `Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.`
|
||||
|
||||
const folders = packages([
|
||||
...Object.keys(built.metafile.inputs),
|
||||
...Object.keys(exported.metafile.inputs),
|
||||
])
|
||||
const notices = await Promise.all(folders.map(notice))
|
||||
await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n'))
|
||||
console.log(`built ${folders.length} packages into ${out}`)
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="main.css">
|
||||
<script src="main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="document"></main>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-4871
File diff suppressed because it is too large.
Load diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "ely-mark-content",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "6.5.2",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@milkdown/kit": "7.22.2",
|
||||
"codemirror": "6.0.2",
|
||||
"hast-util-from-dom": "5.0.1",
|
||||
"hast-util-to-text": "4.0.2",
|
||||
"katex": "0.19.0",
|
||||
"mermaid": "12.1.0",
|
||||
"prosemirror-search": "1.1.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-stringify": "10.0.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vfile": "6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** 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}`)),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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),
|
||||
}))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/** 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
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// 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)) })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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"/>',
|
||||
),
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// 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 ?? '' }),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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?.()
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
/* A text-sized box, centred on the first line. */
|
||||
.milkdown li[data-item-type='task']::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-top: calc(0.25em + (1lh - 1em) / 2);
|
||||
border: 0.09em solid var(--ely-fg-muted);
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task'][data-checked='true']::before {
|
||||
border-color: var(--ely-link);
|
||||
background: var(--ely-link);
|
||||
box-shadow: inset 0 0 0 0.18em var(--ely-bg);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// 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
|
||||
// The text no editor could show; a mode retries it.
|
||||
let broken = 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
|
||||
}
|
||||
|
||||
/** A link the user opens; the app decides. */
|
||||
const linked = (href) => tell({ kind: 'link', generation, href })
|
||||
|
||||
/** 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`; a failed one leaves no holder. */
|
||||
async function build(text, mode) {
|
||||
const holder = document.createElement('div')
|
||||
holder.hidden = true
|
||||
document.getElementById('document').append(holder)
|
||||
try {
|
||||
return await fill(holder, text, mode)
|
||||
} catch (error) {
|
||||
holder.remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds in `holder`; visual falls back to source. */
|
||||
async function fill(holder, text, mode) {
|
||||
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())
|
||||
let made
|
||||
try {
|
||||
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
|
||||
} catch (error) {
|
||||
// A broken editor falls back to source and says so.
|
||||
entry.broke = error.name
|
||||
}
|
||||
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
|
||||
broken = null
|
||||
if (current) drop(current)
|
||||
current = null
|
||||
let entry
|
||||
try {
|
||||
entry = await build(text, want)
|
||||
} catch (error) {
|
||||
if (turn !== latest) return
|
||||
// Source failed too: tell the app, keep the text.
|
||||
broken = text
|
||||
tell({ kind: 'failed', generation, what: error.name })
|
||||
const next = asked
|
||||
asked = null
|
||||
return next && mode(next)
|
||||
}
|
||||
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, broke: entry.broke ?? null })
|
||||
// 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, load) {
|
||||
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
|
||||
clearTimeout(timer)
|
||||
pending = null
|
||||
generation = load
|
||||
version = 0
|
||||
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) {
|
||||
if (broken !== null) return show(broken, next)
|
||||
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)
|
||||
// The app logs a line number, never the query.
|
||||
if (shown === false) tell({ kind: 'missed', generation, line: place.line ?? null })
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
@@ -1,100 +0,0 @@
|
||||
// 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()
|
||||
@@ -1,149 +0,0 @@
|
||||
// Blocks Milkdown cannot write back stay raw text (§14.3).
|
||||
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { canon } from './tables.js'
|
||||
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+$/, '')
|
||||
// Table spacing is free; every cell's source must match.
|
||||
const kept = (node, text) => {
|
||||
const own = written([node])
|
||||
return own === text || canon(own) === canon(text)
|
||||
}
|
||||
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])
|
||||
nodes.push(kept(node, original.slice(...at[index])) ? 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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 {
|
||||
// Lines only: Mermaid's message quotes the document.
|
||||
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)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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 }) => ``).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(),
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// The document's own markers, so blocks write back unchanged.
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/** Writing options most of `text` already uses; tables' for remark-gfm. */
|
||||
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).use(remarkGfm).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
|
||||
case 'table': {
|
||||
// Row nodes leave out quote and list prefixes.
|
||||
const rows = node.children.map((row) => text.slice(row.position.start.offset, row.position.end.offset).trim())
|
||||
const padded = /\| /.test(rows[0])
|
||||
const cells = rows.flatMap((row) => row.replace(/^\||\|$/g, '').split('|'))
|
||||
const inner = (cell) => (padded ? cell.replace(/^ | $/g, '') : cell)
|
||||
vote('tableCellPadding', padded)
|
||||
// Aligned cells carry spaces beyond the padding.
|
||||
vote('tablePipeAlign', cells.some((cell) => /^\s|\s$/.test(inner(cell))))
|
||||
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)]))
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Tables compare by alignment and cell source, not spacing.
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { SKIP, visit } from 'unist-util-visit'
|
||||
|
||||
const reader = unified().use(remarkParse).use(remarkGfm)
|
||||
|
||||
/** A cell's own source, without its padding. */
|
||||
function cell(text, node) {
|
||||
const kids = node.children
|
||||
return kids.length ? text.slice(kids[0].position.start.offset, kids.at(-1).position.end.offset) : ''
|
||||
}
|
||||
|
||||
/** `text` with each table's spacing made canonical. */
|
||||
export function canon(text) {
|
||||
const tables = []
|
||||
visit(reader.parse(text), 'table', (node) => {
|
||||
tables.push(node)
|
||||
return SKIP
|
||||
})
|
||||
let out = text
|
||||
// Later tables first, so earlier offsets hold.
|
||||
for (const table of tables.reverse()) {
|
||||
const width = table.children[0].children.length
|
||||
// GFM fills a short row with empty cells.
|
||||
const rows = table.children.map((row) => {
|
||||
const cells = row.children.map((node) => cell(text, node))
|
||||
return [...cells, ...Array(Math.max(0, width - cells.length)).fill('')].join('|')
|
||||
})
|
||||
const shape = [table.align.join(','), ...rows].join('\n')
|
||||
out = `${out.slice(0, table.position.start.offset)}\u0000${shape}\u0000${out.slice(table.position.end.offset)}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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)]
|
||||
@@ -1,339 +0,0 @@
|
||||
// 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, remarkGFMPlugin } 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, linked) {
|
||||
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])
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
|
||||
const mac = navigator.platform.startsWith('Mac')
|
||||
const links = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
click: (view, event) => {
|
||||
const anchor = event.target.closest?.('a')
|
||||
if (!anchor || !view.dom.contains(anchor)) return false
|
||||
event.preventDefault()
|
||||
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
|
||||
// The DOM href is sanitized; the mark is not.
|
||||
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
|
||||
const mark = node?.marks.find((mark) => mark.type.name === 'link')
|
||||
if (!mark) throw new Error('ely: a link without its mark')
|
||||
linked(mark.attrs.href)
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// 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)
|
||||
const { tableCellPadding, tablePipeAlign, ...marks } = style(markdown)
|
||||
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...marks }))
|
||||
ctx.set(remarkGFMPlugin.options.key, { tableCellPadding, tablePipeAlign })
|
||||
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(links)
|
||||
.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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user