@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.
- package/README.md +5 -0
- package/index.d.ts +33 -5
- package/index.js +132 -11
- 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/
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
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 ``). 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 ``). 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: {
|
|
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
|
|
561
|
-
|
|
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.
|
|
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.
|
|
32
|
-
"@simple-mark/model": "^0.
|
|
31
|
+
"@simple-mark/math": "^0.3.0",
|
|
32
|
+
"@simple-mark/model": "^0.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|