@openmarkdown-dev/editor 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/editor.js +167 -48
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -16,8 +16,8 @@ const editor = mountEditor(document.getElementById("editor")!, createEditorState
16
16
  const bytes = saveMarkdownFile(editor.getDoc(), file); // unedited: the file's own bytes
17
17
  ```
18
18
 
19
+ - **Lossless round-trip:** open a Markdown file and save it without editing, and the file is byte-for-byte what it was. What an edit may rewrite is listed under [Fidelity](#fidelity), and the [fidelity page](https://openmarkdown.dev/editor/docs/fidelity/) has a check you can run yourself.
19
20
  - **Live preview:** headings, lists, tables, math, Mermaid diagrams, callouts and YAML properties render in place; the Markdown shows under the caret.
20
- - **Your file stays yours:** open and save without editing, and the file is byte-for-byte what it was.
21
21
  - **Tables in place:** edit a cell where it is drawn.
22
22
  - **Images:** paste or drop them in, drag a corner to resize.
23
23
  - **Framework-free:** plain TypeScript and DOM. Works with React, Vue, Svelte or nothing. Not build-free: with no bundler, [self-host it](https://github.com/OpenMarkdown-dev/OpenMarkdown-editor/blob/main/docs/guides/self-hosting.md).
@@ -38,7 +38,7 @@ Lossless round-trip: open a Markdown file and save it without editing, and the f
38
38
 
39
39
  That names one sentence of the claim below. For edits, the claim says exactly what changes. This is the whole claim. Every sentence is backed by a test:
40
40
 
41
- > OpenMarkdown edits your Markdown source text directly; it never rebuilds the file from a document tree. Open and save without editing, and the file is byte-for-byte what it was: line endings (LF / CRLF), BOM, final newline, whitespace, tabs, escapes and Unicode spelling untouched. When you edit, only what you touched changes, with a few exceptions, each pinned by a test: a table you edit (or click into a cell of) is re-aligned as a whole when you leave it — alignment, cell text and escapes kept; table commands rewrite that table the same way; indenting a list item or pressing Enter in a numbered list renumbers the affected items; Enter in a list item drops trailing spaces before the cursor; a file with mixed line endings gets uniform ones once you edit it.
41
+ > OpenMarkdown edits your Markdown source text directly; it never rebuilds the file from a document tree. Open and save without editing, and the file is byte-for-byte what it was: line endings (LF / CRLF), BOM, final newline, whitespace, tabs, escapes and Unicode spelling untouched. When you edit, only what you touched changes, with a few exceptions, each pinned by a test: a table you edit (or click into a cell of) is re-aligned as a whole when you leave it — alignment, cell text and escapes kept; table commands rewrite that table the same way; indenting a list item or pressing Enter in a numbered list renumbers the affected items; Enter in a list item drops trailing spaces before the cursor; a file with mixed line endings gets uniform ones once you edit it; typing on a lone blank line between two blocks adds a line break after that line, so the block below stays separate.
42
42
 
43
43
  The claim holds when your host reads and writes the file with the package's
44
44
  `openMarkdownFile` and `saveMarkdownFile`. The editor keeps the document as a string in `\n`
package/dist/editor.js CHANGED
@@ -1377,6 +1377,16 @@ function tableArrowNav(p, row, col, dir) {
1377
1377
  function flattenCellInsert(text) {
1378
1378
  return text.replace(/\r?\n/g, " ");
1379
1379
  }
1380
+ function tableCellInsert(text, before) {
1381
+ let run = /\\*$/.exec(before)[0].length;
1382
+ let out = "";
1383
+ for (const ch of flattenCellInsert(text)) {
1384
+ if (ch === "|" && run % 2 === 0) out += "\\";
1385
+ out += ch;
1386
+ run = ch === "\\" ? run + 1 : 0;
1387
+ }
1388
+ return out;
1389
+ }
1380
1390
  function tableRangeAt(state, pos) {
1381
1391
  for (const bias of [-1, 1]) {
1382
1392
  let node = treeUpTo(state, pos).resolveInner(pos, bias);
@@ -3930,18 +3940,7 @@ function buildPartEditor(outer, text, hooks, opts = {}) {
3930
3940
  }
3931
3941
  })
3932
3942
  ),
3933
- opts.singleLine ? EditorState.transactionFilter.of((tr) => {
3934
- let broken = false;
3935
- tr.changes.iterChanges((_a, _b, _c, _d, inserted) => {
3936
- if (/\r?\n/.test(inserted.toString())) broken = true;
3937
- });
3938
- if (!broken) return tr;
3939
- const changes = [];
3940
- tr.changes.iterChanges((fromA, toA, _b, _c, inserted) => {
3941
- changes.push({ from: fromA, to: toA, insert: flattenCellInsert(inserted.toString()) });
3942
- });
3943
- return { changes, selection: tr.selection, userEvent: tr.annotation(Transaction.userEvent) };
3944
- }) : [],
3943
+ opts.accept ? acceptFilter(opts.accept) : [],
3945
3944
  EditorView.updateListener.of((u) => {
3946
3945
  if (!u.docChanged) return;
3947
3946
  if (u.transactions.some((t) => t.annotation(reseed))) return;
@@ -3966,7 +3965,7 @@ function buildPartEditor(outer, text, hooks, opts = {}) {
3966
3965
  })
3967
3966
  });
3968
3967
  parts.set(host, view);
3969
- setTimeout(() => {
3968
+ queueMicrotask(() => {
3970
3969
  if (!host.isConnected) return;
3971
3970
  const selection = entrySelection(opts.caret, view.state.doc.length, pointerPlaced);
3972
3971
  if (!selection) return;
@@ -3993,6 +3992,29 @@ function reseedPart(view, text) {
3993
3992
  annotations: reseed.of(true)
3994
3993
  });
3995
3994
  }
3995
+ function acceptFilter(accept) {
3996
+ return EditorState.transactionFilter.of((tr) => {
3997
+ if (!tr.docChanged) return tr;
3998
+ const changes = [];
3999
+ let rewritten = false;
4000
+ tr.changes.iterChanges((fromA, toA, _b, _c, inserted) => {
4001
+ const text = inserted.toString();
4002
+ const ok = accept(text, tr.startState.sliceDoc(0, fromA));
4003
+ if (ok !== text) rewritten = true;
4004
+ changes.push({ from: fromA, to: toA, insert: ok });
4005
+ });
4006
+ if (!rewritten) return tr;
4007
+ const next = tr.startState.changes(changes);
4008
+ const back = tr.changes.invert(tr.startState.doc);
4009
+ const selection = tr.selection ? EditorSelection.create(
4010
+ tr.selection.ranges.map(
4011
+ (r) => EditorSelection.range(next.mapPos(back.mapPos(r.anchor, -1), 1), next.mapPos(back.mapPos(r.head, -1), 1))
4012
+ ),
4013
+ tr.selection.mainIndex
4014
+ ) : void 0;
4015
+ return { changes: next, selection, userEvent: tr.annotation(Transaction.userEvent) };
4016
+ });
4017
+ }
3996
4018
  function headText(doc) {
3997
4019
  const n = Math.min(doc.lines, 100);
3998
4020
  return doc.sliceString(0, doc.line(n).to);
@@ -4198,7 +4220,7 @@ class PropertiesWidget extends WidgetType {
4198
4220
  },
4199
4221
  onKey: (e) => this.onKey(e, view, index, part)
4200
4222
  },
4201
- { singleLine: true, caret: view.state.field(activePropField, false)?.caret }
4223
+ { accept: flattenCellInsert, caret: view.state.field(activePropField, false)?.caret }
4202
4224
  );
4203
4225
  return host;
4204
4226
  }
@@ -4408,6 +4430,29 @@ const undoLanding = ViewPlugin.define((view) => ({
4408
4430
  if (land) queueMicrotask(() => settleLanding(view, u.state, land));
4409
4431
  }
4410
4432
  }));
4433
+ function strandedIn(state) {
4434
+ const hooks = state.facet(blockCaret);
4435
+ if (!hooks) return null;
4436
+ const sel = state.selection.main;
4437
+ if (!sel.empty) return null;
4438
+ const pos = sel.head;
4439
+ const block = hooks.editable(state).find((b) => b.from < pos && pos <= b.to);
4440
+ if (!block || hooks.leave(state).length > 0) return null;
4441
+ return { block, pos };
4442
+ }
4443
+ const strandedCaret = ViewPlugin.define((view) => ({
4444
+ update(u) {
4445
+ if (!u.docChanged && !u.selectionSet) return;
4446
+ if (u.transactions.some((tr) => tr.isUserEvent("undo") || tr.isUserEvent("redo"))) return;
4447
+ if (!strandedIn(u.state)) return;
4448
+ queueMicrotask(() => {
4449
+ if (!view.dom.isConnected || view.state !== u.state || view.composing) return;
4450
+ const hit = strandedIn(view.state);
4451
+ const hooks = view.state.facet(blockCaret);
4452
+ if (hit && hooks) hooks.enterAt(view, hit.block, hit.pos);
4453
+ });
4454
+ }
4455
+ }));
4411
4456
  function navigateTo(view, pos) {
4412
4457
  const line = view.state.doc.lineAt(Math.max(0, Math.min(pos, view.state.doc.length)));
4413
4458
  view.dispatch({
@@ -4417,6 +4462,9 @@ function navigateTo(view, pos) {
4417
4462
  });
4418
4463
  view.focus();
4419
4464
  }
4465
+ function isRevealed(state, range) {
4466
+ return state.facet(livePreviewConfig).revealOnSelection && state.selection.ranges.some((r) => r.from <= range.to && r.to >= (range.revealFrom ?? range.from));
4467
+ }
4420
4468
  function loadMathThenRedraw(view) {
4421
4469
  ensureMath(() => {
4422
4470
  try {
@@ -4876,7 +4924,7 @@ class TableWidget extends WidgetType {
4876
4924
  },
4877
4925
  onKey: (e, part) => this.onCellKey(e, view, part, row, col)
4878
4926
  },
4879
- { singleLine: true, caret }
4927
+ { accept: tableCellInsert, caret }
4880
4928
  );
4881
4929
  if (align && align !== "none") host.style.textAlign = align;
4882
4930
  return host;
@@ -5148,26 +5196,72 @@ function mermaidReason(e) {
5148
5196
  const reason = `${lines[0]} ${lines[lines.length - 1]}`;
5149
5197
  return reason.length > 200 ? `${reason.slice(0, 199)}…` : reason;
5150
5198
  }
5151
- function requestMermaid(body, scheme, load, done) {
5199
+ function requestMermaid(body, scheme, load, done, wanted) {
5152
5200
  const key = cacheKey(body, scheme);
5153
5201
  if (cache.has(key)) return;
5154
5202
  const waiting = inflight.get(key);
5155
5203
  if (waiting) {
5156
- waiting.add(done);
5204
+ waiting.set(done, wanted);
5157
5205
  return;
5158
5206
  }
5159
- const waiters = /* @__PURE__ */ new Set([done]);
5207
+ const waiters = /* @__PURE__ */ new Map([[done, wanted]]);
5160
5208
  inflight.set(key, waiters);
5161
5209
  queue = queue.then(async () => {
5162
5210
  const mermaid = await loadMermaid(load);
5163
- inflight.delete(key);
5164
- if (!mermaid) return;
5211
+ if (!mermaid || ![...waiters.values()].some((w) => w())) {
5212
+ inflight.delete(key);
5213
+ return;
5214
+ }
5165
5215
  const result = await attemptRenderAsync(() => renderWith(mermaid, body, scheme));
5166
5216
  rememberMermaid(body, scheme, result);
5167
- for (const fn of waiters) queueMicrotask(fn);
5217
+ inflight.delete(key);
5218
+ for (const fn of waiters.keys()) queueMicrotask(fn);
5168
5219
  });
5169
5220
  }
5170
5221
  const schemeOf = (state) => state.facet(EditorView.darkTheme) ? "dark" : "light";
5222
+ function drawnMermaidBodies(state, viewport) {
5223
+ const bodies = [];
5224
+ progressiveTree(state).iterate({
5225
+ enter: (node) => {
5226
+ if (node.name !== "FencedCode") return;
5227
+ const fence = mermaidFence(state, node);
5228
+ if (fence && !isRevealed(state, fence)) {
5229
+ bodies.push({ body: fence.body, near: fence.to >= viewport.from && fence.from <= viewport.to });
5230
+ }
5231
+ return false;
5232
+ }
5233
+ });
5234
+ bodies.sort((a, b) => Number(b.near) - Number(a.near));
5235
+ return bodies.map((b) => b.body);
5236
+ }
5237
+ class MermaidRequests {
5238
+ constructor(redraw) {
5239
+ this.redraw = redraw;
5240
+ }
5241
+ redraw;
5242
+ alive = true;
5243
+ wanted = /* @__PURE__ */ new Set();
5244
+ request(state, viewport) {
5245
+ const load = state.facet(mermaidLoader);
5246
+ if (!load) return;
5247
+ const scheme = schemeOf(state);
5248
+ const bodies = drawnMermaidBodies(state, viewport);
5249
+ const wanted = new Set(bodies.map((b) => `${scheme}
5250
+ ${b}`));
5251
+ this.wanted = wanted;
5252
+ const done = () => {
5253
+ if (this.alive) this.redraw();
5254
+ };
5255
+ for (const body of bodies) {
5256
+ const key = `${scheme}
5257
+ ${body}`;
5258
+ requestMermaid(body, scheme, load, done, () => this.alive && this.wanted.has(key));
5259
+ }
5260
+ }
5261
+ destroy() {
5262
+ this.alive = false;
5263
+ }
5264
+ }
5171
5265
  const mermaidEntry = {
5172
5266
  id: "mermaid",
5173
5267
  mode: "A",
@@ -5188,38 +5282,22 @@ const mermaidEntry = {
5188
5282
  class {
5189
5283
  constructor(view) {
5190
5284
  this.view = view;
5285
+ this.requests = new MermaidRequests(() => view.dispatch({ effects: asyncRenderDone.of(null) }));
5191
5286
  this.request();
5192
5287
  }
5193
5288
  view;
5194
- alive = true;
5289
+ requests;
5195
5290
  update(u) {
5196
- if (u.docChanged || treeChanged(u) || u.startState.facet(EditorView.darkTheme) !== u.state.facet(EditorView.darkTheme)) {
5291
+ if (u.docChanged || // Which diagrams are drawn follows the selection (reveal) — design D3.
5292
+ u.selectionSet || treeChanged(u) || u.startState.facet(EditorView.darkTheme) !== u.state.facet(EditorView.darkTheme)) {
5197
5293
  this.request();
5198
5294
  }
5199
5295
  }
5200
5296
  destroy() {
5201
- this.alive = false;
5297
+ this.requests.destroy();
5202
5298
  }
5203
- /** Ask for every diagram in the document, those in view first. */
5204
5299
  request() {
5205
- const { state, viewport } = this.view;
5206
- const load = state.facet(mermaidLoader);
5207
- if (!load) return;
5208
- const scheme = schemeOf(state);
5209
- const bodies = [];
5210
- progressiveTree(state).iterate({
5211
- enter: (node) => {
5212
- if (node.name !== "FencedCode") return;
5213
- const fence = mermaidFence(state, node);
5214
- if (fence) bodies.push({ body: fence.body, near: fence.to >= viewport.from && fence.from <= viewport.to });
5215
- return false;
5216
- }
5217
- });
5218
- bodies.sort((a, b) => Number(b.near) - Number(a.near));
5219
- const redraw = () => {
5220
- if (this.alive) this.view.dispatch({ effects: asyncRenderDone.of(null) });
5221
- };
5222
- for (const { body } of bodies) requestMermaid(body, scheme, load, redraw);
5300
+ this.requests.request(this.view.state, this.view.viewport);
5223
5301
  }
5224
5302
  }
5225
5303
  )
@@ -5294,9 +5372,8 @@ function buildBlockDecorations(state) {
5294
5372
  const config = state.facet(livePreviewConfig);
5295
5373
  const deco = [];
5296
5374
  const failed = [];
5297
- const revealed = (c) => config.revealOnSelection && state.selection.ranges.some((r) => r.from <= c.to && r.to >= (c.revealFrom ?? c.from));
5298
5375
  const push2 = (entry, c) => {
5299
- if (entry.mode === "A" && revealed(c)) return;
5376
+ if (entry.mode === "A" && isRevealed(state, c)) return;
5300
5377
  deco.push(
5301
5378
  Decoration.replace({
5302
5379
  // Every block carries its selected state from here, the one exit, so no
@@ -5321,7 +5398,7 @@ function buildBlockDecorations(state) {
5321
5398
  if (!entry || !entry.range || !entry.widget) return;
5322
5399
  const range = entry.range(state, node);
5323
5400
  if (!range) return false;
5324
- if (entry.mode === "A" && revealed({ ...range })) return false;
5401
+ if (entry.mode === "A" && isRevealed(state, range)) return false;
5325
5402
  const widget = entry.widget;
5326
5403
  const out = attemptRender(() => widget(state, node, range));
5327
5404
  if (out.kind === "rendered") push2(entry, { ...range, widget: out.value });
@@ -6091,12 +6168,12 @@ const headingFoldToggle = ViewPlugin.fromClass(
6091
6168
  function itemFoldRange(state, item) {
6092
6169
  const first = state.doc.lineAt(item.from);
6093
6170
  let last = state.doc.lineAt(Math.max(item.from, item.to));
6094
- while (last.number > first.number && (isBlank(state, last.from) || item.to <= last.from)) {
6171
+ while (last.number > first.number && (isBlank$1(state, last.from) || item.to <= last.from)) {
6095
6172
  last = state.doc.line(last.number - 1);
6096
6173
  }
6097
6174
  return last.number > first.number ? { from: first.to, to: last.to } : null;
6098
6175
  }
6099
- function isBlank(state, lineFrom) {
6176
+ function isBlank$1(state, lineFrom) {
6100
6177
  const p = linePrefix(state, lineFrom);
6101
6178
  return !/\S/.test(state.sliceDoc(p.end, p.line.to));
6102
6179
  }
@@ -6976,6 +7053,43 @@ function imagePaste(docPath, getSave, getNotify = () => void 0) {
6976
7053
  }
6977
7054
  });
6978
7055
  }
7056
+ const USER_INPUT = ["input.type", "input.paste", "input.drop"];
7057
+ const isBlank = (line) => /^[ \t]*$/.test(line.text);
7058
+ function loneSeparator(state, doc, n) {
7059
+ if (n <= 1 || n >= doc.lines) return null;
7060
+ const line = doc.line(n);
7061
+ if (!isBlank(line) || isBlank(doc.line(n - 1)) || isBlank(doc.line(n + 1))) return null;
7062
+ const tree = treeUpTo(state, line.from);
7063
+ for (const side of [-1, 1]) {
7064
+ const pos = side < 0 ? line.from : line.to;
7065
+ if (tree.resolveInner(pos, side).name !== "Document") return null;
7066
+ }
7067
+ return line;
7068
+ }
7069
+ function separatorBreak(tr) {
7070
+ if (!tr.docChanged || !USER_INPUT.some((e) => tr.isUserEvent(e))) return null;
7071
+ const ranges = [];
7072
+ tr.changes.iterChanges((fromA2, toA2, _fromB, toB2) => ranges.push({ fromA: fromA2, toA: toA2, toB: toB2 }));
7073
+ if (ranges.length !== 1) return null;
7074
+ const { fromA, toA, toB } = ranges[0];
7075
+ const start = tr.startState;
7076
+ const at = start.doc.lineAt(fromA);
7077
+ if (start.doc.lineAt(toA).number !== at.number) return null;
7078
+ const sep = loneSeparator(start, start.doc, at.number);
7079
+ if (!sep) return null;
7080
+ const doc = tr.newDoc;
7081
+ const edited = doc.lineAt(toB);
7082
+ if (isBlank(edited)) return null;
7083
+ const below = doc.lineAt(tr.changes.mapPos(sep.to + 1, 1));
7084
+ if (below.number !== edited.number + 1) return null;
7085
+ return { from: sep.to, insert: start.lineBreak };
7086
+ }
7087
+ const separatorKeepsBlocks = EditorState.transactionFilter.of((tr) => {
7088
+ const add2 = separatorBreak(tr);
7089
+ if (!add2) return tr;
7090
+ const spec = { changes: add2 };
7091
+ return [tr, spec];
7092
+ });
6979
7093
  function changedRange(current, next) {
6980
7094
  if (current === next) return null;
6981
7095
  const max = Math.min(current.length, next.length);
@@ -7295,6 +7409,11 @@ function buildEditorState(options, loadMermaid2) {
7295
7409
  // Undo / redo land at the change, not at history's old caret (spec:
7296
7410
  // caret-rules R2 / R2a / R2b) — one filter for every undo entry point.
7297
7411
  undoLanding,
7412
+ strandedCaret,
7413
+ // Typing on the only blank line between two blocks keeps the block below
7414
+ // separate (spec: editor-fidelity N8). Every mode: the file's structure
7415
+ // is the same whether it is rendered or not.
7416
+ separatorKeepsBlocks,
7298
7417
  drawSelection(),
7299
7418
  // Drop caret: draws a cursor at the drop position while dragging over the
7300
7419
  // editor, so a sidebar file drag (sidebar-drag-link) shows exactly where
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmarkdown-dev/editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A live-preview Markdown editor on CodeMirror 6 that edits the Markdown source itself, for a lossless round-trip: open and save without editing, and the file is byte-for-byte what it was.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  "markdown-editor",
34
34
  "editor",
35
35
  "codemirror",
36
+ "codemirror-6",
36
37
  "live-preview",
37
38
  "wysiwyg",
38
39
  "embeddable",