@simple-mark/codemirror 0.2.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 +132 -11
  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, mathSource, parseTable, previewBlocks, 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) => {
@@ -557,15 +666,17 @@ function imageRef(image, resolve) {
557
666
  alt: image.alt,
558
667
  problem: `No definition for ${image.raw}`
559
668
  };
560
- const src = resolve(image.src);
561
- return src === null ? {
669
+ const [src, ...fallbacks] = resolvedUrls(resolve, image.src);
670
+ if (src === void 0) return {
562
671
  raw: image.raw,
563
- src,
672
+ src: null,
564
673
  alt: image.alt,
565
674
  problem: `Can't load ${image.raw}`
566
- } : {
675
+ };
676
+ return {
567
677
  raw: image.raw,
568
678
  src,
679
+ ...fallbacks.length ? { fallbacks } : {},
569
680
  alt: image.alt
570
681
  };
571
682
  }
@@ -626,9 +737,17 @@ var ImageWidget = class extends WidgetType {
626
737
  img.decoding = "async";
627
738
  img.src = image.src;
628
739
  img.alt = image.alt;
629
- img.title = image.raw;
740
+ img.title = `${image.raw} (click to open)`;
741
+ img.addEventListener("click", () => followLink(view, image.raw, true));
630
742
  img.onload = settled;
743
+ const fallbacks = [...image.fallbacks ?? []];
631
744
  img.onerror = () => {
745
+ const next = fallbacks.shift();
746
+ if (next !== void 0) {
747
+ watchImage(img, next);
748
+ img.src = next;
749
+ return;
750
+ }
632
751
  img.replaceWith(problem(`Image not found: ${image.raw}`));
633
752
  settled();
634
753
  };
@@ -965,6 +1084,7 @@ function simpleMark(theme = defaultTheme) {
965
1084
  markdownTheme.of(theme),
966
1085
  markdownDecorations,
967
1086
  previews(),
1087
+ links(),
968
1088
  EditorView.theme({
969
1089
  "&": { color: theme.color },
970
1090
  ".cm-scroller": {
@@ -976,7 +1096,7 @@ function simpleMark(theme = defaultTheme) {
976
1096
  }
977
1097
  /** A Markdown editor in `parent`. The text is kept exactly as given, line endings included. */
978
1098
  function createEditor(parent, options = {}) {
979
- const { doc = "", readOnly = false, theme = defaultTheme, resolveImage, onChange, extensions = [] } = options;
1099
+ const { doc = "", readOnly = false, theme = defaultTheme, resolveImage, openLink, onChange, extensions = [] } = options;
980
1100
  const useHistory = options.history ?? true;
981
1101
  return new EditorView({
982
1102
  parent,
@@ -989,6 +1109,7 @@ function createEditor(parent, options = {}) {
989
1109
  EditorView.lineWrapping,
990
1110
  simpleMark(theme),
991
1111
  resolveImage ? imageResolver.of(resolveImage) : [],
1112
+ openLink ? linkOpener.of(openLink) : [],
992
1113
  EditorState.readOnly.of(readOnly),
993
1114
  EditorView.editable.of(!readOnly),
994
1115
  onChange ? EditorView.updateListener.of((update) => update.docChanged && onChange(update.state.sliceDoc(), update)) : [],
@@ -998,4 +1119,4 @@ function createEditor(parent, options = {}) {
998
1119
  });
999
1120
  }
1000
1121
  //#endregion
1001
- 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.2.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.2.0",
32
- "@simple-mark/model": "^0.2.0"
31
+ "@simple-mark/math": "^0.3.0",
32
+ "@simple-mark/model": "^0.3.0"
33
33
  }
34
34
  }