@simple-mark/codemirror 0.1.0 → 0.3.0

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 (4) hide show
  1. package/README.md +5 -0
  2. package/index.d.ts +33 -5
  3. package/index.js +150 -85
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -11,6 +11,7 @@ const view = createEditor(document.getElementById('editor')!, {
11
11
  doc: '# Hello **world**\n\n| a | b |\n|---|---|\n| 1 | 2 |\n',
12
12
  onChange: (text) => save(text), // in the document's own line endings
13
13
  resolveImage: (src) => new URL(src, base).href,
14
+ openLink: (target) => open(target), // { kind: 'web' | 'file' | 'image', url }
14
15
  });
15
16
  ```
16
17
 
@@ -19,6 +20,10 @@ const view = createEditor(document.getElementById('editor')!, {
19
20
  them with an IME is safe.
20
21
  - **Images** and `$$` math preview below their source; `$inline$` math in a tooltip. Math
21
22
  (`@simple-mark/math`) loads only when a document has math.
23
+ - **Links**: Ctrl+Click (⌘+Click on macOS) follows a link — a plain click keeps placing the
24
+ cursor, since links are editable text. `#heading` links jump within the document; other
25
+ schemes than http(s)/mailto are refused; web pages and paths go to `openLink` (default: a
26
+ new browser tab). A click on an image preview opens the image.
22
27
  - **Line endings** are kept (`\r\n`, `\r`), so an unedited file saves back byte for byte.
23
28
  - Accurate scroll heights for documents with many images and formulas.
24
29
  - `simpleMark(theme)` gives the extensions for your own `EditorState`.
package/index.d.ts CHANGED
@@ -1,11 +1,34 @@
1
1
  import { EditorState, Extension, Facet, StateCommand } from "@codemirror/state";
2
2
  import { DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
3
3
  import { LineLook, Theme } from "@simple-mark/model";
4
- //#region packages/codemirror/src/previews.d.ts
5
- /** Turns an image path as written in the document into a URL to load, or null if it can't. */
6
- type ImageResolver = (src: string) => string | null;
7
- /** How image paths are resolved; by default they are used as written. */
4
+ //#region packages/codemirror/src/links.d.ts
5
+ /** What to open: a web address, or a path as written (an image, another document, …). */
6
+ interface LinkTarget {
7
+ kind: 'web' | 'file' | 'image';
8
+ url: string;
9
+ }
10
+ /** Opens a link target; hosts replace this with their own (see `createEditor`'s `openLink`). */
11
+ type LinkOpener = (target: LinkTarget, view: EditorView) => void;
12
+ /** How links are opened. */
13
+ export declare const linkOpener: Facet<LinkOpener, LinkOpener>;
14
+ /**
15
+ * Follows a link target as written (`isImage`: from `![alt](src)`). Returns false when there
16
+ * is nothing to open: no target, an unsafe scheme, or an anchor no heading has.
17
+ */
18
+ export declare function followLink(view: EditorView, target: string | null, isImage?: boolean): boolean;
19
+ /** Ctrl/Cmd+Click follows links; `#anchors` jump to headings. */
20
+ export declare function links(): Extension;
21
+ //#endregion
22
+ //#region packages/codemirror/src/image-resolver.d.ts
23
+ /**
24
+ * Turns a path as written in the document into a URL to load, or several to try in order
25
+ * (the first that loads is shown), or null if it can't be loaded.
26
+ */
27
+ type ImageResolver = (src: string) => string | readonly string[] | null;
28
+ /** How image (and file) paths are resolved; by default they are used as written. */
8
29
  export declare const imageResolver: Facet<ImageResolver, ImageResolver>;
30
+ //#endregion
31
+ //#region packages/codemirror/src/previews.d.ts
9
32
  /** Image and math previews; the source text always stays visible. */
10
33
  export declare function previews(): Extension;
11
34
  //#endregion
@@ -63,6 +86,11 @@ export interface EditorOptions {
63
86
  lineSeparator?: string;
64
87
  /** Turns an image path from the document into a URL to load (default: as written). */
65
88
  resolveImage?: ImageResolver;
89
+ /**
90
+ * Opens a followed link (Ctrl/Cmd+Click) or a clicked image preview. Default: web addresses
91
+ * and paths (resolved like images) in a new browser tab.
92
+ */
93
+ openLink?: LinkOpener;
66
94
  /** Off when the host owns undo (VS Code). */
67
95
  history?: boolean;
68
96
  /** Called after every change with the full text, in the original line endings. */
@@ -72,4 +100,4 @@ export interface EditorOptions {
72
100
  /** A Markdown editor in `parent`. The text is kept exactly as given, line endings included. */
73
101
  export declare function createEditor(parent: HTMLElement, options?: EditorOptions): EditorView;
74
102
  //#endregion
75
- export type { ImageResolver };
103
+ export type { ImageResolver, LinkOpener, LinkTarget };
package/index.js CHANGED
@@ -2,8 +2,115 @@ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
2
2
  import { commonmarkLanguage, markdown } from "@codemirror/lang-markdown";
3
3
  import { EditorState, Facet, Prec, StateEffect, StateField } from "@codemirror/state";
4
4
  import { Decoration, EditorView, ViewPlugin, WidgetType, hoverTooltip, keymap, showTooltip } from "@codemirror/view";
5
- import { cellAt, charWidth, defaultTheme, markdownExtensions, markdownStyles, parseTable, renderTable, resolveLineLook, resolveLook, rowLine } from "@simple-mark/model";
5
+ import { cellAt, charWidth, defaultTheme, headingAnchors, linkAt, linkKind, markdownExtensions, markdownStyles, mathSource, parseTable, previewBlocks, renderTable, resolveLineLook, resolveLook, rowLine } from "@simple-mark/model";
6
6
  import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
7
+ //#region packages/codemirror/src/image-resolver.ts
8
+ /** The URLs a resolver gives for `src`, in order. */
9
+ function resolvedUrls(resolve, src) {
10
+ const result = resolve(src);
11
+ return result === null ? [] : typeof result === "string" ? [result] : [...result];
12
+ }
13
+ /** How image (and file) paths are resolved; by default they are used as written. */
14
+ const imageResolver = Facet.define({ combine: (values) => values[0] ?? ((src) => src) });
15
+ //#endregion
16
+ //#region packages/codemirror/src/links.ts
17
+ /** In a browser: web addresses and paths (resolved like images) open in a new tab. */
18
+ const openInBrowser = (target, view) => {
19
+ const url = target.kind === "web" ? target.url : resolvedUrls(view.state.facet(imageResolver), target.url)[0];
20
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
21
+ };
22
+ /** How links are opened. */
23
+ const linkOpener = Facet.define({ combine: (values) => values[0] ?? openInBrowser });
24
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
25
+ /** The key held to follow a link: Cmd on macOS, Ctrl elsewhere. */
26
+ const followKeyName = isMac ? "⌘" : "Ctrl";
27
+ const followKey = (event) => isMac ? event.metaKey : event.ctrlKey;
28
+ /**
29
+ * Follows a link target as written (`isImage`: from `![alt](src)`). Returns false when there
30
+ * is nothing to open: no target, an unsafe scheme, or an anchor no heading has.
31
+ */
32
+ function followLink(view, target, isImage = false) {
33
+ if (target === null) return false;
34
+ const { kind, url } = linkKind(target);
35
+ if (kind === "unsafe") return false;
36
+ if (kind === "anchor") {
37
+ const wanted = decodeURIComponent(url.slice(1)).toLowerCase();
38
+ const heading = headingAnchors(syntaxTree(view.state), view.state.doc.toString()).find((h) => h.anchor === wanted);
39
+ if (!heading) return false;
40
+ view.dispatch({
41
+ selection: { anchor: heading.from },
42
+ effects: EditorView.scrollIntoView(heading.from, {
43
+ y: "start",
44
+ yMargin: 24
45
+ })
46
+ });
47
+ view.focus();
48
+ return true;
49
+ }
50
+ view.state.facet(linkOpener)({
51
+ kind: kind === "file" && isImage ? "image" : kind,
52
+ url
53
+ }, view);
54
+ return true;
55
+ }
56
+ /** Ctrl/Cmd+Click on a link follows it (instead of adding a cursor there). */
57
+ const clickToFollow = EditorView.domEventHandlers({ mousedown(event, view) {
58
+ if (event.button !== 0 || !followKey(event) || event.shiftKey || event.altKey) return false;
59
+ if (!(event.target instanceof Element) || !event.target.closest(".sm-link, .sm-url")) return false;
60
+ const pos = view.posAtCoords({
61
+ x: event.clientX,
62
+ y: event.clientY
63
+ });
64
+ if (pos === null) return false;
65
+ const link = linkAt(syntaxTree(view.state), view.state.doc.toString(), pos);
66
+ if (!link) return false;
67
+ event.preventDefault();
68
+ followLink(view, link.target, link.kind === "image");
69
+ return true;
70
+ } });
71
+ /** While Ctrl/Cmd is held, links look clickable (pointer, underline). */
72
+ const followKeyHint = ViewPlugin.fromClass(class {
73
+ view;
74
+ constructor(view) {
75
+ this.view = view;
76
+ for (const type of [
77
+ "keydown",
78
+ "keyup",
79
+ "mousemove"
80
+ ]) window.addEventListener(type, this.track, true);
81
+ window.addEventListener("blur", this.clear);
82
+ }
83
+ destroy() {
84
+ for (const type of [
85
+ "keydown",
86
+ "keyup",
87
+ "mousemove"
88
+ ]) window.removeEventListener(type, this.track, true);
89
+ window.removeEventListener("blur", this.clear);
90
+ }
91
+ track = (event) => {
92
+ this.view.dom.classList.toggle("sm-follow-links", followKey(event));
93
+ };
94
+ clear = () => {
95
+ this.view.dom.classList.remove("sm-follow-links");
96
+ };
97
+ });
98
+ const followTheme = EditorView.theme({
99
+ "&.sm-follow-links .sm-link, &.sm-follow-links .sm-url": {
100
+ cursor: "pointer",
101
+ textDecoration: "underline"
102
+ },
103
+ ".sm-image-preview img": { cursor: "pointer" }
104
+ });
105
+ /** Ctrl/Cmd+Click follows links; `#anchors` jump to headings. */
106
+ function links() {
107
+ return [
108
+ clickToFollow,
109
+ followKeyHint,
110
+ followTheme
111
+ ];
112
+ }
113
+ //#endregion
7
114
  //#region packages/codemirror/src/decorations.ts
8
115
  /** The theme the editor draws with. */
9
116
  const markdownTheme = Facet.define({ combine: (values) => values[0] ?? defaultTheme });
@@ -144,9 +251,13 @@ var MarkdownDecorations = class {
144
251
  const key = `mark:${styles.join(" ")}`;
145
252
  let deco = this.cache.get(key);
146
253
  if (!deco) {
254
+ const linked = styles.includes("link") || styles.includes("url");
147
255
  deco = Decoration.mark({
148
256
  class: styles.map((s) => `sm-${s}`).join(" "),
149
- attributes: { style: lookCss(resolveLook([], styles, this.theme), this.theme) }
257
+ attributes: {
258
+ style: lookCss(resolveLook([], styles, this.theme), this.theme),
259
+ ...linked ? { title: `${followKeyName}+Click to open` } : {}
260
+ }
150
261
  });
151
262
  this.cache.set(key, deco);
152
263
  }
@@ -497,8 +608,6 @@ function lineHeightHints() {
497
608
  }
498
609
  //#endregion
499
610
  //#region packages/codemirror/src/previews.ts
500
- /** How image paths are resolved; by default they are used as written. */
501
- const imageResolver = Facet.define({ combine: (values) => values[0] ?? ((src) => src) });
502
611
  /** Fills `dom` with rendered math now if known, otherwise when it is ready. */
503
612
  function renderInto(dom, tex, display, onDone) {
504
613
  const show = (result) => {
@@ -549,56 +658,26 @@ var MathBlockWidget = class extends WidgetType {
549
658
  return dom;
550
659
  }
551
660
  };
552
- /** The TeX of a math node: the text between its delimiters, without quote markers. */
553
- function mathTex(state, node) {
554
- const marks = node.getChildren("MathMark");
555
- if (marks.length < 2) return null;
556
- if (node.name === "InlineMath") return state.sliceDoc(marks[0].to, marks[1].from);
557
- return node.getChildren("MathText").map((text) => state.sliceDoc(text.from, text.to)).join("\n");
558
- }
559
- const normalizeLabel = (label) => label.slice(1, -1).trim().replace(/\s+/g, " ").toLowerCase();
560
- /** A link destination as written, without the optional angle brackets (`<a b.png>`). */
561
- const destination = (state, url) => state.sliceDoc(url.from, url.to).replace(/^<(.*)>$/, "$1");
562
- /** Link reference definitions (`[label]: url`) of the document, by normalized label. */
563
- function linkDefinitions(state, tree) {
564
- const definitions = /* @__PURE__ */ new Map();
565
- for (let node = tree.topNode.firstChild; node; node = node.nextSibling) {
566
- if (node.name !== "LinkReference") continue;
567
- const label = node.getChild("LinkLabel");
568
- const url = node.getChild("URL");
569
- if (label && url) {
570
- const key = normalizeLabel(state.sliceDoc(label.from, label.to));
571
- if (!definitions.has(key)) definitions.set(key, destination(state, url));
572
- }
573
- }
574
- return definitions;
575
- }
576
- function imageRef(state, node, definitions, resolve) {
577
- const marks = node.getChildren("LinkMark");
578
- const alt = marks.length >= 2 ? state.sliceDoc(marks[0].to, marks[1].from) : "";
579
- const url = node.getChild("URL");
580
- let raw = url ? destination(state, url) : void 0;
581
- if (raw === void 0) {
582
- const labelNode = node.getChild("LinkLabel");
583
- const labelText = labelNode && labelNode.to - labelNode.from > 2 ? state.sliceDoc(labelNode.from, labelNode.to) : `[${alt}]`;
584
- raw = definitions.get(normalizeLabel(labelText));
585
- if (raw === void 0) return {
586
- raw: labelText,
587
- src: null,
588
- alt,
589
- problem: `No definition for ${labelText}`
590
- };
591
- }
592
- const src = resolve(raw);
593
- return src === null ? {
594
- raw,
595
- src,
596
- alt,
597
- problem: `Can't load ${raw}`
598
- } : {
599
- raw,
661
+ /** An image from the document with its path resolved by the host. */
662
+ function imageRef(image, resolve) {
663
+ if (image.src === null) return {
664
+ raw: image.raw,
665
+ src: null,
666
+ alt: image.alt,
667
+ problem: `No definition for ${image.raw}`
668
+ };
669
+ const [src, ...fallbacks] = resolvedUrls(resolve, image.src);
670
+ if (src === void 0) return {
671
+ raw: image.raw,
672
+ src: null,
673
+ alt: image.alt,
674
+ problem: `Can't load ${image.raw}`
675
+ };
676
+ return {
677
+ raw: image.raw,
600
678
  src,
601
- alt
679
+ ...fallbacks.length ? { fallbacks } : {},
680
+ alt: image.alt
602
681
  };
603
682
  }
604
683
  var ImageWidget = class extends WidgetType {
@@ -658,9 +737,17 @@ var ImageWidget = class extends WidgetType {
658
737
  img.decoding = "async";
659
738
  img.src = image.src;
660
739
  img.alt = image.alt;
661
- img.title = image.raw;
740
+ img.title = `${image.raw} (click to open)`;
741
+ img.addEventListener("click", () => followLink(view, image.raw, true));
662
742
  img.onload = settled;
743
+ const fallbacks = [...image.fallbacks ?? []];
663
744
  img.onerror = () => {
745
+ const next = fallbacks.shift();
746
+ if (next !== void 0) {
747
+ watchImage(img, next);
748
+ img.src = next;
749
+ return;
750
+ }
664
751
  img.replaceWith(problem(`Image not found: ${image.raw}`));
665
752
  settled();
666
753
  };
@@ -672,35 +759,11 @@ var ImageWidget = class extends WidgetType {
672
759
  };
673
760
  /** What to preview below which lines: the images of each line, and closed non-empty $$ blocks. */
674
761
  function blockPreviewSources(state) {
675
- const tree = syntaxTree(state);
676
762
  const resolve = state.facet(imageResolver);
677
- const definitions = linkDefinitions(state, tree);
678
- const imagesByLine = /* @__PURE__ */ new Map();
679
- const previews = [];
680
- tree.iterate({ enter(node) {
681
- if (node.name === "Image") {
682
- const at = state.doc.lineAt(node.to).to;
683
- let images = imagesByLine.get(at);
684
- if (!images) {
685
- imagesByLine.set(at, images = []);
686
- previews.push({
687
- at,
688
- images
689
- });
690
- }
691
- images.push(imageRef(state, node.node, definitions, resolve));
692
- return false;
693
- }
694
- if (node.name === "BlockMath") {
695
- const tex = mathTex(state, node.node);
696
- if (tex !== null && tex.trim()) previews.push({
697
- at: state.doc.lineAt(node.to).to,
698
- tex
699
- });
700
- return false;
701
- }
702
- } });
703
- return previews;
763
+ return previewBlocks(syntaxTree(state), state.doc.toString()).map((block) => "tex" in block ? block : {
764
+ at: block.at,
765
+ images: block.images.map((image) => imageRef(image, resolve))
766
+ });
704
767
  }
705
768
  function blockPreviews(state) {
706
769
  const ranges = blockPreviewSources(state).map((preview) => {
@@ -728,7 +791,7 @@ function inlineMathAt(state, pos) {
728
791
  }
729
792
  function mathTooltip(state, pos) {
730
793
  const node = inlineMathAt(state, pos);
731
- const tex = node && mathTex(state, node);
794
+ const tex = node && mathSource(node, (from, to) => state.sliceDoc(from, to));
732
795
  if (!node || !tex) return null;
733
796
  const display = node.getChild("MathMark").to - node.from === 2;
734
797
  return {
@@ -1021,6 +1084,7 @@ function simpleMark(theme = defaultTheme) {
1021
1084
  markdownTheme.of(theme),
1022
1085
  markdownDecorations,
1023
1086
  previews(),
1087
+ links(),
1024
1088
  EditorView.theme({
1025
1089
  "&": { color: theme.color },
1026
1090
  ".cm-scroller": {
@@ -1032,7 +1096,7 @@ function simpleMark(theme = defaultTheme) {
1032
1096
  }
1033
1097
  /** A Markdown editor in `parent`. The text is kept exactly as given, line endings included. */
1034
1098
  function createEditor(parent, options = {}) {
1035
- const { doc = "", readOnly = false, theme = defaultTheme, resolveImage, onChange, extensions = [] } = options;
1099
+ const { doc = "", readOnly = false, theme = defaultTheme, resolveImage, openLink, onChange, extensions = [] } = options;
1036
1100
  const useHistory = options.history ?? true;
1037
1101
  return new EditorView({
1038
1102
  parent,
@@ -1045,6 +1109,7 @@ function createEditor(parent, options = {}) {
1045
1109
  EditorView.lineWrapping,
1046
1110
  simpleMark(theme),
1047
1111
  resolveImage ? imageResolver.of(resolveImage) : [],
1112
+ openLink ? linkOpener.of(openLink) : [],
1048
1113
  EditorState.readOnly.of(readOnly),
1049
1114
  EditorView.editable.of(!readOnly),
1050
1115
  onChange ? EditorView.updateListener.of((update) => update.docChanged && onChange(update.state.sliceDoc(), update)) : [],
@@ -1054,4 +1119,4 @@ function createEditor(parent, options = {}) {
1054
1119
  });
1055
1120
  }
1056
1121
  //#endregion
1057
- export { createEditor, imageResolver, insertRowBelow, keepLineSeparator, lookCss, markdownDecorations, markdownTheme, nextCell, previews, previousCell, simpleMark, tableEditing };
1122
+ export { createEditor, followLink, imageResolver, insertRowBelow, keepLineSeparator, linkOpener, links, lookCss, markdownDecorations, markdownTheme, nextCell, previews, previousCell, simpleMark, tableEditing };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-mark/codemirror",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "CodeMirror 6 editor that styles Markdown without hiding its syntax.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "@codemirror/state": "^6.7.6",
29
29
  "@codemirror/view": "^6.43.15",
30
30
  "@lezer/common": "^1.5.3",
31
- "@simple-mark/math": "^0.1.0",
32
- "@simple-mark/model": "^0.1.0"
31
+ "@simple-mark/math": "^0.3.0",
32
+ "@simple-mark/model": "^0.3.0"
33
33
  }
34
34
  }