Commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"validators": {
|
||||
"*.{ts,tsx}": ["@parcel/validator-typescript"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "noot",
|
||||
"version": "1.0.0",
|
||||
"source": "src/index.html",
|
||||
"license": "MIT",
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
"scripts": {
|
||||
"start": "parcel",
|
||||
"build": "parcel build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/validator-typescript": "^2.3.2",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"parcel": "^2.3.2",
|
||||
"typescript": ">=3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/basic-setup": "^0.19.1",
|
||||
"@codemirror/commands": "^0.19.8",
|
||||
"@codemirror/lang-markdown": "^0.19.6",
|
||||
"@codemirror/state": "^0.19.7",
|
||||
"@codemirror/view": "^0.19.42",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets";
|
||||
import { indentWithTab, standardKeymap } from "@codemirror/commands";
|
||||
import { history, historyKeymap } from "@codemirror/history";
|
||||
import { indentOnInput } from "@codemirror/language";
|
||||
import { bracketMatching } from "@codemirror/matchbrackets";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { EditorState, StateField } from "@codemirror/state";
|
||||
import { drawSelection, dropCursor, EditorView, highlightSpecialChars, keymap, } from "@codemirror/view";
|
||||
import * as commands from "./commands";
|
||||
import { markdown } from "./markdown";
|
||||
import { lineWrapper } from "./lineWrapper";
|
||||
import customMarkDown from "./parser";
|
||||
import customMarkdownStyle from "./style";
|
||||
import { HttpFileSystem } from "./fs";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useEffect, useRef } from "react";
|
||||
const fs = new HttpFileSystem("http://localhost:2222/fs");
|
||||
class Editor {
|
||||
constructor(parent, currentNote, text) {
|
||||
this.view = new EditorView({
|
||||
state: this.createEditorState(text),
|
||||
parent: parent,
|
||||
});
|
||||
this.currentNote = currentNote;
|
||||
}
|
||||
load(name, text) {
|
||||
this.currentNote = name;
|
||||
this.view.setState(this.createEditorState(text));
|
||||
}
|
||||
async save() {
|
||||
await fs.writeNote(this.currentNote, this.view.state.sliceDoc());
|
||||
}
|
||||
focus() {
|
||||
this.view.focus();
|
||||
}
|
||||
createEditorState(text) {
|
||||
return EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
customMarkdownStyle,
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
EditorView.lineWrapping,
|
||||
lineWrapper([
|
||||
{ selector: "ATXHeading1", class: "line-h1" },
|
||||
{ selector: "ATXHeading2", class: "line-h2" },
|
||||
{ selector: "ListItem", class: "line-li" },
|
||||
{ selector: "Blockquote", class: "line-blockquote" },
|
||||
{ selector: "CodeBlock", class: "line-code" },
|
||||
{ selector: "FencedCode", class: "line-fenced-code" },
|
||||
]),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...standardKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
indentWithTab,
|
||||
{
|
||||
key: "Ctrl-b",
|
||||
mac: "Cmd-b",
|
||||
run: commands.insertMarker("**"),
|
||||
},
|
||||
{
|
||||
key: "Ctrl-i",
|
||||
mac: "Cmd-i",
|
||||
run: commands.insertMarker("_"),
|
||||
},
|
||||
{
|
||||
key: "Ctrl-s",
|
||||
mac: "Cmd-s",
|
||||
run: (target) => {
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
console.log("Saving");
|
||||
await this.save();
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
EditorView.domEventHandlers({
|
||||
click: (event, view) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
console.log("Navigate click");
|
||||
let coords = view.posAtCoords(event);
|
||||
console.log("Coords", view.state.doc.sliceString(coords, coords + 1));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
markdown({
|
||||
base: customMarkDown,
|
||||
}),
|
||||
StateField.define({
|
||||
create: () => null,
|
||||
update: (value, transaction) => {
|
||||
if (transaction.docChanged) {
|
||||
console.log("Something changed");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
export const App = () => {
|
||||
const editorRef = useRef();
|
||||
useEffect(() => {
|
||||
let editor = new Editor(editorRef.current, "", "");
|
||||
editor.focus();
|
||||
// @ts-ignore
|
||||
window.editor = editor;
|
||||
fs.readNote("start").then((text) => {
|
||||
editor.load("start", text);
|
||||
});
|
||||
}, []);
|
||||
return (_jsxs(_Fragment, { children: [_jsx("div", { id: "top", children: "Hello" }, void 0), _jsx("div", { id: "editor", ref: editorRef }, void 0), _jsx("div", { id: "bottom", children: "Bottom" }, void 0)] }, void 0));
|
||||
};
|
||||
ReactDOM.render(_jsx(App, {}, void 0), document.body);
|
||||
@@ -0,0 +1,173 @@
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets";
|
||||
import { indentWithTab, standardKeymap } from "@codemirror/commands";
|
||||
import { history, historyKeymap } from "@codemirror/history";
|
||||
import { indentOnInput } from "@codemirror/language";
|
||||
import { bracketMatching } from "@codemirror/matchbrackets";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { EditorState, StateField } from "@codemirror/state";
|
||||
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
} from "@codemirror/view";
|
||||
import * as commands from "./commands";
|
||||
import { markdown } from "./markdown";
|
||||
import { lineWrapper } from "./lineWrapper";
|
||||
import customMarkDown from "./parser";
|
||||
import customMarkdownStyle from "./style";
|
||||
import { HttpFileSystem } from "./fs";
|
||||
|
||||
import ReactDOM from "react-dom";
|
||||
import { MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
const fs = new HttpFileSystem("http://localhost:2222/fs");
|
||||
|
||||
type AppState = {
|
||||
currentNote: string;
|
||||
};
|
||||
|
||||
class Editor {
|
||||
view: EditorView;
|
||||
currentNote: string;
|
||||
|
||||
constructor(parent: Element, currentNote: string, text: string) {
|
||||
this.view = new EditorView({
|
||||
state: this.createEditorState(text),
|
||||
parent: parent,
|
||||
});
|
||||
this.currentNote = currentNote;
|
||||
}
|
||||
|
||||
load(name: string, text: string) {
|
||||
this.currentNote = name;
|
||||
this.view.setState(this.createEditorState(text));
|
||||
}
|
||||
|
||||
async save() {
|
||||
await fs.writeNote(this.currentNote, this.view.state.sliceDoc());
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
private createEditorState(text: string): EditorState {
|
||||
return EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
customMarkdownStyle,
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
EditorView.lineWrapping,
|
||||
lineWrapper([
|
||||
{ selector: "ATXHeading1", class: "line-h1" },
|
||||
{ selector: "ATXHeading2", class: "line-h2" },
|
||||
{ selector: "ListItem", class: "line-li" },
|
||||
{ selector: "Blockquote", class: "line-blockquote" },
|
||||
{ selector: "CodeBlock", class: "line-code" },
|
||||
{ selector: "FencedCode", class: "line-fenced-code" },
|
||||
]),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...standardKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
indentWithTab,
|
||||
{
|
||||
key: "Ctrl-b",
|
||||
mac: "Cmd-b",
|
||||
run: commands.insertMarker("**"),
|
||||
},
|
||||
{
|
||||
key: "Ctrl-i",
|
||||
mac: "Cmd-i",
|
||||
run: commands.insertMarker("_"),
|
||||
},
|
||||
{
|
||||
key: "Ctrl-s",
|
||||
mac: "Cmd-s",
|
||||
run: (target: EditorView): boolean => {
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
console.log("Saving");
|
||||
await this.save();
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
EditorView.domEventHandlers({
|
||||
click: (event: MouseEvent, view: EditorView) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
console.log("Navigate click");
|
||||
let coords = view.posAtCoords(event);
|
||||
console.log(
|
||||
"Coords",
|
||||
view.state.doc.sliceString(coords!, coords! + 1)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
markdown({
|
||||
base: customMarkDown,
|
||||
}),
|
||||
StateField.define({
|
||||
create: () => null,
|
||||
update: (value, transaction) => {
|
||||
if (transaction.docChanged) {
|
||||
console.log("Something changed");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function TopBar({ editor }: { editor: Editor | null }) {
|
||||
return (
|
||||
<div id="top">
|
||||
This is the top bar, do something cool: {editor?.currentNote}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let editor = new Editor(editorRef.current!, "", "");
|
||||
editor.focus();
|
||||
// @ts-ignore
|
||||
window.editor = editor;
|
||||
fs.readNote("start").then((text) => {
|
||||
editor.load("start", text);
|
||||
});
|
||||
setEditor(editor);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar editor={editor} />
|
||||
<div id="editor" ref={editorRef}></div>
|
||||
<div id="bottom">Bottom</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.render(<App />, document.body);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||
import { Text } from "@codemirror/text";
|
||||
export function insertMarker(marker) {
|
||||
return ({ state, dispatch }) => {
|
||||
const changes = state.changeByRange((range) => {
|
||||
const isBoldBefore = state.sliceDoc(range.from - marker.length, range.from) === marker;
|
||||
const isBoldAfter = state.sliceDoc(range.to, range.to + marker.length) === marker;
|
||||
const changes = [];
|
||||
changes.push(isBoldBefore ? {
|
||||
from: range.from - marker.length,
|
||||
to: range.from,
|
||||
insert: Text.of([''])
|
||||
} : {
|
||||
from: range.from,
|
||||
insert: Text.of([marker]),
|
||||
});
|
||||
changes.push(isBoldAfter ? {
|
||||
from: range.to,
|
||||
to: range.to + marker.length,
|
||||
insert: Text.of([''])
|
||||
} : {
|
||||
from: range.to,
|
||||
insert: Text.of([marker]),
|
||||
});
|
||||
const extendBefore = isBoldBefore ? -marker.length : marker.length;
|
||||
const extendAfter = isBoldAfter ? -marker.length : marker.length;
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(range.from + extendBefore, range.to + extendAfter),
|
||||
};
|
||||
});
|
||||
dispatch(state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
annotations: Transaction.userEvent.of('input'),
|
||||
}));
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EditorSelection, EditorState, StateCommand, Transaction } from "@codemirror/state";
|
||||
import { Text } from "@codemirror/text";
|
||||
|
||||
export function insertMarker(marker: string): StateCommand {
|
||||
return ({ state, dispatch }) => {
|
||||
const changes = state.changeByRange((range) => {
|
||||
const isBoldBefore = state.sliceDoc(range.from - marker.length, range.from) === marker;
|
||||
const isBoldAfter = state.sliceDoc(range.to, range.to + marker.length) === marker;
|
||||
const changes = [];
|
||||
|
||||
changes.push(isBoldBefore ? {
|
||||
from: range.from - marker.length,
|
||||
to: range.from,
|
||||
insert: Text.of([''])
|
||||
} : {
|
||||
from: range.from,
|
||||
insert: Text.of([marker]),
|
||||
})
|
||||
|
||||
changes.push(isBoldAfter ? {
|
||||
from: range.to,
|
||||
to: range.to + marker.length,
|
||||
insert: Text.of([''])
|
||||
} : {
|
||||
from: range.to,
|
||||
insert: Text.of([marker]),
|
||||
})
|
||||
|
||||
const extendBefore = isBoldBefore ? -marker.length : marker.length;
|
||||
const extendAfter = isBoldAfter ? -marker.length : marker.length;
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(range.from + extendBefore, range.to + extendAfter),
|
||||
}
|
||||
})
|
||||
|
||||
dispatch(
|
||||
state.update(changes, {
|
||||
scrollIntoView: true,
|
||||
annotations: Transaction.userEvent.of('input'),
|
||||
})
|
||||
)
|
||||
|
||||
return true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Tag } from '@codemirror/highlight';
|
||||
export const WikiLinkTag = Tag.define();
|
||||
export const TagTag = Tag.define();
|
||||
export const MentionTag = Tag.define();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Tag } from '@codemirror/highlight';
|
||||
|
||||
export const WikiLinkTag = Tag.define();
|
||||
export const TagTag = Tag.define();
|
||||
export const MentionTag = Tag.define();
|
||||
@@ -0,0 +1,24 @@
|
||||
export class HttpFileSystem {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
}
|
||||
async listNotes() {
|
||||
let req = await fetch(this.url, {
|
||||
method: 'GET'
|
||||
});
|
||||
return (await req.json()).map((name) => ({ name }));
|
||||
}
|
||||
async readNote(name) {
|
||||
let req = await fetch(`${this.url}/${name}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
return await req.text();
|
||||
}
|
||||
async writeNote(name, text) {
|
||||
let req = await fetch(`${this.url}/${name}`, {
|
||||
method: 'PUT',
|
||||
body: text
|
||||
});
|
||||
await req.text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
export interface NoteMeta {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FileSystem {
|
||||
listNotes(): Promise<NoteMeta>;
|
||||
readNote(name: string): Promise<string>;
|
||||
writeNote(name: string, text: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class HttpFileSystem implements FileSystem {
|
||||
url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
async listNotes(): Promise<NoteMeta> {
|
||||
let req = await fetch(this.url, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
return (await req.json()).map((name: string) => ({ name }));
|
||||
}
|
||||
async readNote(name: string): Promise<string> {
|
||||
let req = await fetch(`${this.url}/${name}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
return await req.text();
|
||||
}
|
||||
async writeNote(name: string, text: string): Promise<void> {
|
||||
let req = await fetch(`${this.url}/${name}`, {
|
||||
method: 'PUT',
|
||||
body: text
|
||||
});
|
||||
await req.text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Noot</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script type="module" src="app.tsx"></script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Decoration, ViewPlugin } from '@codemirror/view';
|
||||
function wrapLines(view, wrapElements) {
|
||||
let widgets = [];
|
||||
for (let { from, to } of view.visibleRanges) {
|
||||
const doc = view.state.doc;
|
||||
syntaxTree(view.state).iterate({
|
||||
from, to,
|
||||
enter: (type, from, to) => {
|
||||
const bodyText = doc.sliceString(from, to);
|
||||
// console.log("Enter", type.name, bodyText);
|
||||
for (let wrapElement of wrapElements) {
|
||||
if (type.name == wrapElement.selector) {
|
||||
const bodyText = doc.sliceString(from, to);
|
||||
// console.log("Found", type.name, "with: ", bodyText);
|
||||
let idx = from;
|
||||
for (let line of bodyText.split("\n")) {
|
||||
widgets.push(Decoration.line({
|
||||
class: wrapElement.class,
|
||||
}).range(doc.lineAt(idx).from));
|
||||
idx += line.length + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave(type, from, to) {
|
||||
// console.log("Leaving", type.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
// console.log("All widgets", widgets);
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
export const lineWrapper = (wrapElements) => ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.decorations = wrapLines(view, wrapElements);
|
||||
}
|
||||
update(update) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = wrapLines(update.view, wrapElements);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet, EditorView, ViewPlugin,
|
||||
ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { Range } from '@codemirror/rangeset';
|
||||
|
||||
interface WrapElement {
|
||||
selector: string;
|
||||
class: string;
|
||||
}
|
||||
function wrapLines(view: EditorView, wrapElements: WrapElement[]) {
|
||||
let widgets: Range<Decoration>[] = [];
|
||||
for (let { from, to } of view.visibleRanges) {
|
||||
const doc = view.state.doc;
|
||||
syntaxTree(view.state).iterate({
|
||||
from, to,
|
||||
enter: (type, from, to) => {
|
||||
const bodyText = doc.sliceString(from, to);
|
||||
for (let wrapElement of wrapElements) {
|
||||
if (type.name == wrapElement.selector) {
|
||||
const bodyText = doc.sliceString(from, to);
|
||||
let idx = from;
|
||||
for (let line of bodyText.split("\n")) {
|
||||
widgets.push(Decoration.line({
|
||||
class: wrapElement.class,
|
||||
}).range(doc.lineAt(idx).from));
|
||||
idx += line.length + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave(type, from: number, to: number) {
|
||||
}
|
||||
});
|
||||
}
|
||||
// console.log("All widgets", widgets);
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
export const lineWrapper = (wrapElements: WrapElement[]) => ViewPlugin.fromClass(class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = wrapLines(view, wrapElements);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = wrapLines(update.view, wrapElements);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: v => v.decorations,
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { markdownLanguage } from "./markdown";
|
||||
function nodeStart(node, doc) {
|
||||
return doc.sliceString(node.from, node.from + 50);
|
||||
}
|
||||
class Context {
|
||||
constructor(node, from, to, spaceBefore, spaceAfter, type, item) {
|
||||
this.node = node;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.spaceBefore = spaceBefore;
|
||||
this.spaceAfter = spaceAfter;
|
||||
this.type = type;
|
||||
this.item = item;
|
||||
}
|
||||
blank(trailing = true) {
|
||||
let result = this.spaceBefore;
|
||||
if (this.node.name == "Blockquote")
|
||||
result += ">";
|
||||
else
|
||||
for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--)
|
||||
result += " ";
|
||||
return result + (trailing ? this.spaceAfter : "");
|
||||
}
|
||||
marker(doc, add) {
|
||||
let number = this.node.name == "OrderedList" ? String((+itemNumber(this.item, doc)[2] + add)) : "";
|
||||
return this.spaceBefore + number + this.type + this.spaceAfter;
|
||||
}
|
||||
}
|
||||
function getContext(node, line, doc) {
|
||||
let nodes = [];
|
||||
for (let cur = node; cur && cur.name != "Document"; cur = cur.parent) {
|
||||
if (cur.name == "ListItem" || cur.name == "Blockquote")
|
||||
nodes.push(cur);
|
||||
}
|
||||
let context = [], pos = 0;
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
let node = nodes[i], match, start = pos;
|
||||
if (node.name == "Blockquote" && (match = /^[ \t]*>( ?)/.exec(line.slice(pos)))) {
|
||||
pos += match[0].length;
|
||||
context.push(new Context(node, start, pos, "", match[1], ">", null));
|
||||
}
|
||||
else if (node.name == "ListItem" && node.parent.name == "OrderedList" &&
|
||||
(match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(nodeStart(node, doc)))) {
|
||||
let after = match[3], len = match[0].length;
|
||||
if (after.length >= 4) {
|
||||
after = after.slice(0, after.length - 4);
|
||||
len -= 4;
|
||||
}
|
||||
pos += len;
|
||||
context.push(new Context(node.parent, start, pos, match[1], after, match[2], node));
|
||||
}
|
||||
else if (node.name == "ListItem" && node.parent.name == "BulletList" &&
|
||||
(match = /^([ \t]*)([-+*])([ \t]+)/.exec(nodeStart(node, doc)))) {
|
||||
let after = match[3], len = match[0].length;
|
||||
if (after.length > 4) {
|
||||
after = after.slice(0, after.length - 4);
|
||||
len -= 4;
|
||||
}
|
||||
pos += len;
|
||||
context.push(new Context(node.parent, start, pos, match[1], after, match[2], node));
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
function itemNumber(item, doc) {
|
||||
return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10));
|
||||
}
|
||||
function renumberList(after, doc, changes, offset = 0) {
|
||||
for (let prev = -1, node = after;;) {
|
||||
if (node.name == "ListItem") {
|
||||
let m = itemNumber(node, doc);
|
||||
let number = +m[2];
|
||||
if (prev >= 0) {
|
||||
if (number != prev + 1)
|
||||
return;
|
||||
changes.push({ from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset) });
|
||||
}
|
||||
prev = number;
|
||||
}
|
||||
let next = node.nextSibling;
|
||||
if (!next)
|
||||
break;
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
/// This command, when invoked in Markdown context with cursor
|
||||
/// selection(s), will create a new line with the markup for
|
||||
/// blockquotes and lists that were active on the old line. If the
|
||||
/// cursor was directly after the end of the markup for the old line,
|
||||
/// trailing whitespace and list markers are removed from that line.
|
||||
///
|
||||
/// The command does nothing in non-Markdown context, so it should
|
||||
/// not be used as the only binding for Enter (even in a Markdown
|
||||
/// document, HTML and code regions might use a different language).
|
||||
export const insertNewlineContinueMarkup = ({ state, dispatch }) => {
|
||||
let tree = syntaxTree(state), { doc } = state;
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
if (!range.empty || !markdownLanguage.isActiveAt(state, range.from))
|
||||
return dont = { range };
|
||||
let pos = range.from, line = doc.lineAt(pos);
|
||||
let context = getContext(tree.resolveInner(pos, -1), line.text, doc);
|
||||
while (context.length && context[context.length - 1].from > pos - line.from)
|
||||
context.pop();
|
||||
if (!context.length)
|
||||
return dont = { range };
|
||||
let inner = context[context.length - 1];
|
||||
if (inner.to - inner.spaceAfter.length > pos - line.from)
|
||||
return dont = { range };
|
||||
let emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to));
|
||||
// Empty line in list
|
||||
if (inner.item && emptyLine) {
|
||||
// First list item or blank line before: delete a level of markup
|
||||
if (inner.node.firstChild.to >= pos ||
|
||||
line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text)) {
|
||||
let next = context.length > 1 ? context[context.length - 2] : null;
|
||||
let delTo, insert = "";
|
||||
if (next && next.item) { // Re-add marker for the list at the next level
|
||||
delTo = line.from + next.from;
|
||||
insert = next.marker(doc, 1);
|
||||
}
|
||||
else {
|
||||
delTo = line.from + (next ? next.to : 0);
|
||||
}
|
||||
let changes = [{ from: delTo, to: pos, insert }];
|
||||
if (inner.node.name == "OrderedList")
|
||||
renumberList(inner.item, doc, changes, -2);
|
||||
if (next && next.node.name == "OrderedList")
|
||||
renumberList(next.item, doc, changes);
|
||||
return { range: EditorSelection.cursor(delTo + insert.length), changes };
|
||||
}
|
||||
else { // Move this line down
|
||||
let insert = "";
|
||||
for (let i = 0, e = context.length - 2; i <= e; i++)
|
||||
insert += context[i].blank(i < e);
|
||||
insert += state.lineBreak;
|
||||
return { range: EditorSelection.cursor(pos + insert.length), changes: { from: line.from, insert } };
|
||||
}
|
||||
}
|
||||
if (inner.node.name == "Blockquote" && emptyLine && line.from) {
|
||||
let prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text);
|
||||
// Two aligned empty quoted lines in a row
|
||||
if (quoted && quoted.index == inner.from) {
|
||||
let changes = state.changes([{ from: prevLine.from + quoted.index, to: prevLine.to },
|
||||
{ from: line.from + inner.from, to: line.to }]);
|
||||
return { range: range.map(changes), changes };
|
||||
}
|
||||
}
|
||||
let changes = [];
|
||||
if (inner.node.name == "OrderedList")
|
||||
renumberList(inner.item, doc, changes);
|
||||
let insert = state.lineBreak;
|
||||
let continued = inner.item && inner.item.from < line.from;
|
||||
// If not dedented
|
||||
if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)[0].length >= inner.to) {
|
||||
for (let i = 0, e = context.length - 1; i <= e; i++)
|
||||
insert += i == e && !continued ? context[i].marker(doc, 1) : context[i].blank();
|
||||
}
|
||||
let from = pos;
|
||||
while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1)))
|
||||
from--;
|
||||
changes.push({ from, to: pos, insert });
|
||||
return { range: EditorSelection.cursor(from + insert.length), changes };
|
||||
});
|
||||
if (dont)
|
||||
return false;
|
||||
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
|
||||
return true;
|
||||
};
|
||||
function isMark(node) {
|
||||
return node.name == "QuoteMark" || node.name == "ListMark";
|
||||
}
|
||||
function contextNodeForDelete(tree, pos) {
|
||||
let node = tree.resolveInner(pos, -1), scan = pos;
|
||||
if (isMark(node)) {
|
||||
scan = node.from;
|
||||
node = node.parent;
|
||||
}
|
||||
for (let prev; prev = node.childBefore(scan);) {
|
||||
if (isMark(prev)) {
|
||||
scan = prev.from;
|
||||
}
|
||||
else if (prev.name == "OrderedList" || prev.name == "BulletList") {
|
||||
node = prev.lastChild;
|
||||
scan = node.to;
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
/// This command will, when invoked in a Markdown context with the
|
||||
/// cursor directly after list or blockquote markup, delete one level
|
||||
/// of markup. When the markup is for a list, it will be replaced by
|
||||
/// spaces on the first invocation (a further invocation will delete
|
||||
/// the spaces), to make it easy to continue a list.
|
||||
///
|
||||
/// When not after Markdown block markup, this command will return
|
||||
/// false, so it is intended to be bound alongside other deletion
|
||||
/// commands, with a higher precedence than the more generic commands.
|
||||
export const deleteMarkupBackward = ({ state, dispatch }) => {
|
||||
let tree = syntaxTree(state);
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
let pos = range.from, { doc } = state;
|
||||
if (range.empty && markdownLanguage.isActiveAt(state, range.from)) {
|
||||
let line = doc.lineAt(pos);
|
||||
let context = getContext(contextNodeForDelete(tree, pos), line.text, doc);
|
||||
if (context.length) {
|
||||
let inner = context[context.length - 1];
|
||||
let spaceEnd = inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0);
|
||||
// Delete extra trailing space after markup
|
||||
if (pos - line.from > spaceEnd && !/\S/.test(line.text.slice(spaceEnd, pos - line.from)))
|
||||
return { range: EditorSelection.cursor(line.from + spaceEnd),
|
||||
changes: { from: line.from + spaceEnd, to: pos } };
|
||||
if (pos - line.from == spaceEnd) {
|
||||
let start = line.from + inner.from;
|
||||
// Replace a list item marker with blank space
|
||||
if (inner.item && inner.node.from < inner.item.from && /\S/.test(line.text.slice(inner.from, inner.to)))
|
||||
return { range, changes: { from: start, to: line.from + inner.to, insert: inner.blank() } };
|
||||
// Delete one level of indentation
|
||||
if (start < pos)
|
||||
return { range: EditorSelection.cursor(start), changes: { from: start, to: pos } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return dont = { range };
|
||||
});
|
||||
if (dont)
|
||||
return false;
|
||||
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "delete" }));
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import {StateCommand, Text, EditorSelection, ChangeSpec} from "@codemirror/state"
|
||||
import {syntaxTree} from "@codemirror/language"
|
||||
import {SyntaxNode, Tree} from "@lezer/common"
|
||||
import {markdownLanguage} from "./markdown"
|
||||
|
||||
function nodeStart(node: SyntaxNode, doc: Text) {
|
||||
return doc.sliceString(node.from, node.from + 50)
|
||||
}
|
||||
|
||||
class Context {
|
||||
constructor(
|
||||
readonly node: SyntaxNode,
|
||||
readonly from: number,
|
||||
readonly to: number,
|
||||
readonly spaceBefore: string,
|
||||
readonly spaceAfter: string,
|
||||
readonly type: string,
|
||||
readonly item: SyntaxNode | null
|
||||
) {}
|
||||
|
||||
blank(trailing: boolean = true) {
|
||||
let result = this.spaceBefore
|
||||
if (this.node.name == "Blockquote") result += ">"
|
||||
else for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--) result += " "
|
||||
return result + (trailing ? this.spaceAfter : "")
|
||||
}
|
||||
|
||||
marker(doc: Text, add: number) {
|
||||
let number = this.node.name == "OrderedList" ? String((+itemNumber(this.item!, doc)[2] + add)) : ""
|
||||
return this.spaceBefore + number + this.type + this.spaceAfter
|
||||
}
|
||||
}
|
||||
|
||||
function getContext(node: SyntaxNode, line: string, doc: Text) {
|
||||
let nodes = []
|
||||
for (let cur: SyntaxNode | null = node; cur && cur.name != "Document"; cur = cur.parent) {
|
||||
if (cur.name == "ListItem" || cur.name == "Blockquote")
|
||||
nodes.push(cur)
|
||||
}
|
||||
let context = [], pos = 0
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
let node = nodes[i], match, start = pos
|
||||
if (node.name == "Blockquote" && (match = /^[ \t]*>( ?)/.exec(line.slice(pos)))) {
|
||||
pos += match[0].length
|
||||
context.push(new Context(node, start, pos, "", match[1], ">", null))
|
||||
} else if (node.name == "ListItem" && node.parent!.name == "OrderedList" &&
|
||||
(match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(nodeStart(node, doc)))) {
|
||||
let after = match[3], len = match[0].length
|
||||
if (after.length >= 4) { after = after.slice(0, after.length - 4); len -= 4 }
|
||||
pos += len
|
||||
context.push(new Context(node.parent!, start, pos, match[1], after, match[2], node))
|
||||
} else if (node.name == "ListItem" && node.parent!.name == "BulletList" &&
|
||||
(match = /^([ \t]*)([-+*])([ \t]+)/.exec(nodeStart(node, doc)))) {
|
||||
let after = match[3], len = match[0].length
|
||||
if (after.length > 4) { after = after.slice(0, after.length - 4); len -= 4 }
|
||||
pos += len
|
||||
context.push(new Context(node.parent!, start, pos, match[1], after, match[2], node))
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function itemNumber(item: SyntaxNode, doc: Text) {
|
||||
return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10))!
|
||||
}
|
||||
|
||||
function renumberList(after: SyntaxNode, doc: Text, changes: ChangeSpec[], offset = 0) {
|
||||
for (let prev = -1, node = after;;) {
|
||||
if (node.name == "ListItem") {
|
||||
let m = itemNumber(node, doc)
|
||||
let number = +m[2]
|
||||
if (prev >= 0) {
|
||||
if (number != prev + 1) return
|
||||
changes.push({from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset)})
|
||||
}
|
||||
prev = number
|
||||
}
|
||||
let next = node.nextSibling
|
||||
if (!next) break
|
||||
node = next
|
||||
}
|
||||
}
|
||||
|
||||
/// This command, when invoked in Markdown context with cursor
|
||||
/// selection(s), will create a new line with the markup for
|
||||
/// blockquotes and lists that were active on the old line. If the
|
||||
/// cursor was directly after the end of the markup for the old line,
|
||||
/// trailing whitespace and list markers are removed from that line.
|
||||
///
|
||||
/// The command does nothing in non-Markdown context, so it should
|
||||
/// not be used as the only binding for Enter (even in a Markdown
|
||||
/// document, HTML and code regions might use a different language).
|
||||
export const insertNewlineContinueMarkup: StateCommand = ({state, dispatch}) => {
|
||||
let tree = syntaxTree(state), {doc} = state
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
if (!range.empty || !markdownLanguage.isActiveAt(state, range.from)) return dont = {range}
|
||||
let pos = range.from, line = doc.lineAt(pos)
|
||||
let context = getContext(tree.resolveInner(pos, -1), line.text, doc)
|
||||
while (context.length && context[context.length - 1].from > pos - line.from) context.pop()
|
||||
if (!context.length) return dont = {range}
|
||||
let inner = context[context.length - 1]
|
||||
if (inner.to - inner.spaceAfter.length > pos - line.from) return dont = {range}
|
||||
|
||||
let emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to))
|
||||
// Empty line in list
|
||||
if (inner.item && emptyLine) {
|
||||
// First list item or blank line before: delete a level of markup
|
||||
if (inner.node.firstChild!.to >= pos ||
|
||||
line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text)) {
|
||||
let next = context.length > 1 ? context[context.length - 2] : null
|
||||
let delTo, insert = ""
|
||||
if (next && next.item) { // Re-add marker for the list at the next level
|
||||
delTo = line.from + next.from
|
||||
insert = next.marker(doc, 1)
|
||||
} else {
|
||||
delTo = line.from + (next ? next.to : 0)
|
||||
}
|
||||
let changes: ChangeSpec[] = [{from: delTo, to: pos, insert}]
|
||||
if (inner.node.name == "OrderedList") renumberList(inner.item!, doc, changes, -2)
|
||||
if (next && next.node.name == "OrderedList") renumberList(next.item!, doc, changes)
|
||||
return {range: EditorSelection.cursor(delTo + insert.length), changes}
|
||||
} else { // Move this line down
|
||||
let insert = ""
|
||||
for (let i = 0, e = context.length - 2; i <= e; i++) insert += context[i].blank(i < e)
|
||||
insert += state.lineBreak
|
||||
return {range: EditorSelection.cursor(pos + insert.length), changes: {from: line.from, insert}}
|
||||
}
|
||||
}
|
||||
|
||||
if (inner.node.name == "Blockquote" && emptyLine && line.from) {
|
||||
let prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text)
|
||||
// Two aligned empty quoted lines in a row
|
||||
if (quoted && quoted.index == inner.from) {
|
||||
let changes = state.changes([{from: prevLine.from + quoted.index, to: prevLine.to},
|
||||
{from: line.from + inner.from, to: line.to}])
|
||||
return {range: range.map(changes), changes}
|
||||
}
|
||||
}
|
||||
|
||||
let changes: ChangeSpec[] = []
|
||||
if (inner.node.name == "OrderedList") renumberList(inner.item!, doc, changes)
|
||||
let insert = state.lineBreak
|
||||
let continued = inner.item && inner.item.from < line.from
|
||||
// If not dedented
|
||||
if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)![0].length >= inner.to) {
|
||||
for (let i = 0, e = context.length - 1; i <= e; i++)
|
||||
insert += i == e && !continued ? context[i].marker(doc, 1) : context[i].blank()
|
||||
}
|
||||
let from = pos
|
||||
while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1))) from--
|
||||
changes.push({from, to: pos, insert})
|
||||
return {range: EditorSelection.cursor(from + insert.length), changes}
|
||||
})
|
||||
if (dont) return false
|
||||
dispatch(state.update(changes, {scrollIntoView: true, userEvent: "input"}))
|
||||
return true
|
||||
}
|
||||
|
||||
function isMark(node: SyntaxNode) {
|
||||
return node.name == "QuoteMark" || node.name == "ListMark"
|
||||
}
|
||||
|
||||
function contextNodeForDelete(tree: Tree, pos: number) {
|
||||
let node = tree.resolveInner(pos, -1), scan = pos
|
||||
if (isMark(node)) {
|
||||
scan = node.from
|
||||
node = node.parent!
|
||||
}
|
||||
for (let prev; prev = node.childBefore(scan);) {
|
||||
if (isMark(prev)) {
|
||||
scan = prev.from
|
||||
} else if (prev.name == "OrderedList" || prev.name == "BulletList") {
|
||||
node = prev.lastChild!
|
||||
scan = node.to
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/// This command will, when invoked in a Markdown context with the
|
||||
/// cursor directly after list or blockquote markup, delete one level
|
||||
/// of markup. When the markup is for a list, it will be replaced by
|
||||
/// spaces on the first invocation (a further invocation will delete
|
||||
/// the spaces), to make it easy to continue a list.
|
||||
///
|
||||
/// When not after Markdown block markup, this command will return
|
||||
/// false, so it is intended to be bound alongside other deletion
|
||||
/// commands, with a higher precedence than the more generic commands.
|
||||
export const deleteMarkupBackward: StateCommand = ({state, dispatch}) => {
|
||||
let tree = syntaxTree(state)
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
let pos = range.from, {doc} = state
|
||||
if (range.empty && markdownLanguage.isActiveAt(state, range.from)) {
|
||||
let line = doc.lineAt(pos)
|
||||
let context = getContext(contextNodeForDelete(tree, pos), line.text, doc)
|
||||
if (context.length) {
|
||||
let inner = context[context.length - 1]
|
||||
let spaceEnd = inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0)
|
||||
// Delete extra trailing space after markup
|
||||
if (pos - line.from > spaceEnd && !/\S/.test(line.text.slice(spaceEnd, pos - line.from)))
|
||||
return {range: EditorSelection.cursor(line.from + spaceEnd),
|
||||
changes: {from: line.from + spaceEnd, to: pos}}
|
||||
if (pos - line.from == spaceEnd) {
|
||||
let start = line.from + inner.from
|
||||
// Replace a list item marker with blank space
|
||||
if (inner.item && inner.node.from < inner.item.from && /\S/.test(line.text.slice(inner.from, inner.to)))
|
||||
return {range, changes: {from: start, to: line.from + inner.to, insert: inner.blank()}}
|
||||
// Delete one level of indentation
|
||||
if (start < pos)
|
||||
return {range: EditorSelection.cursor(start), changes: {from: start, to: pos}}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dont = {range}
|
||||
})
|
||||
if (dont) return false
|
||||
dispatch(state.update(changes, {scrollIntoView: true, userEvent: "delete"}))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { LanguageSupport } from "@codemirror/language";
|
||||
import { MarkdownParser, parseCode } from "@lezer/markdown";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { commonmarkLanguage, markdownLanguage, mkLang, getCodeParser } from "./markdown";
|
||||
import { insertNewlineContinueMarkup, deleteMarkupBackward } from "./commands";
|
||||
export { commonmarkLanguage, markdownLanguage, insertNewlineContinueMarkup, deleteMarkupBackward };
|
||||
/// A small keymap with Markdown-specific bindings. Binds Enter to
|
||||
/// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup)
|
||||
/// and Backspace to
|
||||
/// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward).
|
||||
export const markdownKeymap = [
|
||||
{ key: "Enter", run: insertNewlineContinueMarkup },
|
||||
{ key: "Backspace", run: deleteMarkupBackward }
|
||||
];
|
||||
const htmlNoMatch = html({ matchClosingTags: false });
|
||||
/// Markdown language support.
|
||||
export function markdown(config = {}) {
|
||||
let { codeLanguages, defaultCodeLanguage, addKeymap = true, base: { parser } = commonmarkLanguage } = config;
|
||||
if (!(parser instanceof MarkdownParser))
|
||||
throw new RangeError("Base parser provided to `markdown` should be a Markdown parser");
|
||||
let extensions = config.extensions ? [config.extensions] : [];
|
||||
let support = [htmlNoMatch.support], defaultCode;
|
||||
if (defaultCodeLanguage instanceof LanguageSupport) {
|
||||
support.push(defaultCodeLanguage.support);
|
||||
defaultCode = defaultCodeLanguage.language;
|
||||
}
|
||||
else if (defaultCodeLanguage) {
|
||||
defaultCode = defaultCodeLanguage;
|
||||
}
|
||||
let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages || [], defaultCode) : undefined;
|
||||
extensions.push(parseCode({ codeParser, htmlParser: htmlNoMatch.language.parser }));
|
||||
if (addKeymap)
|
||||
support.push(Prec.high(keymap.of(markdownKeymap)));
|
||||
return new LanguageSupport(mkLang(parser.configure(extensions)), support);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {Prec} from "@codemirror/state"
|
||||
import {KeyBinding, keymap} from "@codemirror/view"
|
||||
import {Language, LanguageSupport, LanguageDescription} from "@codemirror/language"
|
||||
import {MarkdownExtension, MarkdownParser, parseCode} from "@lezer/markdown"
|
||||
import {html} from "@codemirror/lang-html"
|
||||
import {commonmarkLanguage, markdownLanguage, mkLang, getCodeParser} from "./markdown"
|
||||
import {insertNewlineContinueMarkup, deleteMarkupBackward} from "./commands"
|
||||
export {commonmarkLanguage, markdownLanguage, insertNewlineContinueMarkup, deleteMarkupBackward}
|
||||
|
||||
/// A small keymap with Markdown-specific bindings. Binds Enter to
|
||||
/// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup)
|
||||
/// and Backspace to
|
||||
/// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward).
|
||||
export const markdownKeymap: readonly KeyBinding[] = [
|
||||
{key: "Enter", run: insertNewlineContinueMarkup},
|
||||
{key: "Backspace", run: deleteMarkupBackward}
|
||||
]
|
||||
|
||||
const htmlNoMatch = html({matchClosingTags: false})
|
||||
|
||||
/// Markdown language support.
|
||||
export function markdown(config: {
|
||||
/// When given, this language will be used by default to parse code
|
||||
/// blocks.
|
||||
defaultCodeLanguage?: Language | LanguageSupport,
|
||||
/// A collection of language descriptions to search through for a
|
||||
/// matching language (with
|
||||
/// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName))
|
||||
/// when a fenced code block has an info string.
|
||||
codeLanguages?: readonly LanguageDescription[],
|
||||
/// Set this to false to disable installation of the Markdown
|
||||
/// [keymap](#lang-markdown.markdownKeymap).
|
||||
addKeymap?: boolean,
|
||||
/// Markdown parser
|
||||
/// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension)
|
||||
/// to add to the parser.
|
||||
extensions?: MarkdownExtension,
|
||||
/// The base language to use. Defaults to
|
||||
/// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage).
|
||||
base?: Language
|
||||
} = {}) {
|
||||
let {codeLanguages, defaultCodeLanguage, addKeymap = true, base: {parser} = commonmarkLanguage} = config
|
||||
if (!(parser instanceof MarkdownParser)) throw new RangeError("Base parser provided to `markdown` should be a Markdown parser")
|
||||
let extensions = config.extensions ? [config.extensions] : []
|
||||
let support = [htmlNoMatch.support], defaultCode
|
||||
if (defaultCodeLanguage instanceof LanguageSupport) {
|
||||
support.push(defaultCodeLanguage.support)
|
||||
defaultCode = defaultCodeLanguage.language
|
||||
} else if (defaultCodeLanguage) {
|
||||
defaultCode = defaultCodeLanguage
|
||||
}
|
||||
let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages || [], defaultCode) : undefined
|
||||
extensions.push(parseCode({codeParser, htmlParser: htmlNoMatch.language.parser}))
|
||||
if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap)))
|
||||
return new LanguageSupport(mkLang(parser.configure(extensions)), support)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Language, defineLanguageFacet, languageDataProp, foldNodeProp, indentNodeProp, LanguageDescription, ParseContext } from "@codemirror/language";
|
||||
import { styleTags, tags as t } from "@codemirror/highlight";
|
||||
import { parser as baseParser, GFM, Subscript, Superscript, Emoji } from "@lezer/markdown";
|
||||
const data = defineLanguageFacet({ block: { open: "<!--", close: "-->" } });
|
||||
export const commonmark = baseParser.configure({
|
||||
props: [
|
||||
styleTags({
|
||||
"Blockquote/...": t.quote,
|
||||
HorizontalRule: t.contentSeparator,
|
||||
"ATXHeading1/... SetextHeading1/...": t.heading1,
|
||||
"ATXHeading2/... SetextHeading2/...": t.heading2,
|
||||
"ATXHeading3/...": t.heading3,
|
||||
"ATXHeading4/...": t.heading4,
|
||||
"ATXHeading5/...": t.heading5,
|
||||
"ATXHeading6/...": t.heading6,
|
||||
"Comment CommentBlock": t.comment,
|
||||
Escape: t.escape,
|
||||
Entity: t.character,
|
||||
"Emphasis/...": t.emphasis,
|
||||
"StrongEmphasis/...": t.strong,
|
||||
"Link/... Image/...": t.link,
|
||||
"OrderedList/... BulletList/...": t.list,
|
||||
// "CodeBlock/... FencedCode/...": t.blockComment,
|
||||
"InlineCode CodeText": t.monospace,
|
||||
URL: t.url,
|
||||
"HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": t.processingInstruction,
|
||||
"CodeInfo LinkLabel": t.labelName,
|
||||
LinkTitle: t.string,
|
||||
Paragraph: t.content
|
||||
}),
|
||||
foldNodeProp.add(type => {
|
||||
if (!type.is("Block") || type.is("Document"))
|
||||
return undefined;
|
||||
return (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to });
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Document: () => null
|
||||
}),
|
||||
languageDataProp.add({
|
||||
Document: data
|
||||
})
|
||||
]
|
||||
});
|
||||
export function mkLang(parser) {
|
||||
return new Language(data, parser, parser.nodeSet.types.find(t => t.name == "Document"));
|
||||
}
|
||||
/// Language support for strict CommonMark.
|
||||
export const commonmarkLanguage = mkLang(commonmark);
|
||||
const extended = commonmark.configure([GFM, Subscript, Superscript, Emoji, {
|
||||
props: [
|
||||
styleTags({
|
||||
"TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": t.processingInstruction,
|
||||
"TableHeader/...": t.heading,
|
||||
"Strikethrough/...": t.strikethrough,
|
||||
TaskMarker: t.atom,
|
||||
Task: t.list,
|
||||
Emoji: t.character,
|
||||
"Subscript Superscript": t.special(t.content),
|
||||
TableCell: t.content
|
||||
})
|
||||
]
|
||||
}]);
|
||||
/// Language support for [GFM](https://github.github.com/gfm/) plus
|
||||
/// subscript, superscript, and emoji syntax.
|
||||
export const markdownLanguage = mkLang(extended);
|
||||
export function getCodeParser(languages, defaultLanguage) {
|
||||
return (info) => {
|
||||
let found = info && LanguageDescription.matchLanguageName(languages, info, true);
|
||||
if (!found)
|
||||
return defaultLanguage ? defaultLanguage.parser : null;
|
||||
if (found.support)
|
||||
return found.support.language.parser;
|
||||
return ParseContext.getSkippingParser(found.load());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Language, defineLanguageFacet, languageDataProp, foldNodeProp, indentNodeProp,
|
||||
LanguageDescription, ParseContext
|
||||
} from "@codemirror/language"
|
||||
import {styleTags, tags as t} from "@codemirror/highlight"
|
||||
import {parser as baseParser, MarkdownParser, GFM, Subscript, Superscript, Emoji, MarkdownConfig} from "@lezer/markdown"
|
||||
|
||||
const data = defineLanguageFacet({block: {open: "<!--", close: "-->"}})
|
||||
|
||||
export const commonmark = baseParser.configure({
|
||||
props: [
|
||||
styleTags({
|
||||
"Blockquote/...": t.quote,
|
||||
HorizontalRule: t.contentSeparator,
|
||||
"ATXHeading1/... SetextHeading1/...": t.heading1,
|
||||
"ATXHeading2/... SetextHeading2/...": t.heading2,
|
||||
"ATXHeading3/...": t.heading3,
|
||||
"ATXHeading4/...": t.heading4,
|
||||
"ATXHeading5/...": t.heading5,
|
||||
"ATXHeading6/...": t.heading6,
|
||||
"Comment CommentBlock": t.comment,
|
||||
Escape: t.escape,
|
||||
Entity: t.character,
|
||||
"Emphasis/...": t.emphasis,
|
||||
"StrongEmphasis/...": t.strong,
|
||||
"Link/... Image/...": t.link,
|
||||
"OrderedList/... BulletList/...": t.list,
|
||||
|
||||
// "CodeBlock/... FencedCode/...": t.blockComment,
|
||||
"InlineCode CodeText": t.monospace,
|
||||
URL: t.url,
|
||||
"HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": t.processingInstruction,
|
||||
"CodeInfo LinkLabel": t.labelName,
|
||||
LinkTitle: t.string,
|
||||
Paragraph: t.content
|
||||
}),
|
||||
foldNodeProp.add(type => {
|
||||
if (!type.is("Block") || type.is("Document")) return undefined
|
||||
return (tree, state) => ({from: state.doc.lineAt(tree.from).to, to: tree.to})
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Document: () => null
|
||||
}),
|
||||
languageDataProp.add({
|
||||
Document: data
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export function mkLang(parser: MarkdownParser) {
|
||||
return new Language(data, parser, parser.nodeSet.types.find(t => t.name == "Document")!)
|
||||
}
|
||||
|
||||
/// Language support for strict CommonMark.
|
||||
export const commonmarkLanguage = mkLang(commonmark)
|
||||
|
||||
const extended = commonmark.configure([GFM, Subscript, Superscript, Emoji, {
|
||||
props: [
|
||||
styleTags({
|
||||
"TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": t.processingInstruction,
|
||||
"TableHeader/...": t.heading,
|
||||
"Strikethrough/...": t.strikethrough,
|
||||
TaskMarker: t.atom,
|
||||
Task: t.list,
|
||||
Emoji: t.character,
|
||||
"Subscript Superscript": t.special(t.content),
|
||||
TableCell: t.content
|
||||
})
|
||||
]
|
||||
}])
|
||||
|
||||
/// Language support for [GFM](https://github.github.com/gfm/) plus
|
||||
/// subscript, superscript, and emoji syntax.
|
||||
export const markdownLanguage = mkLang(extended)
|
||||
|
||||
export function getCodeParser(languages: readonly LanguageDescription[],
|
||||
defaultLanguage?: Language) {
|
||||
return (info: string) => {
|
||||
let found = info && LanguageDescription.matchLanguageName(languages, info, true)
|
||||
if (!found) return defaultLanguage ? defaultLanguage.parser : null
|
||||
if (found.support) return found.support.language.parser
|
||||
return ParseContext.getSkippingParser(found.load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { styleTags } from '@codemirror/highlight';
|
||||
import { commonmark, mkLang } from "./markdown/markdown";
|
||||
import * as ct from './customtags';
|
||||
const WikiLink = {
|
||||
defineNodes: ["WikiLink"],
|
||||
parseInline: [{
|
||||
name: "WikiLink",
|
||||
parse(cx, next, pos) {
|
||||
let match;
|
||||
if (next != 91 /* '[' */ || !(match = /^\[[^\]]+\]\]/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("WikiLink", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const AtMention = {
|
||||
defineNodes: ["AtMention"],
|
||||
parseInline: [{
|
||||
name: "AtMention",
|
||||
parse(cx, next, pos) {
|
||||
let match;
|
||||
if (next != 64 /* '@' */ || !(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("AtMention", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const TagLink = {
|
||||
defineNodes: ["TagLink"],
|
||||
parseInline: [{
|
||||
name: "TagLink",
|
||||
parse(cx, next, pos) {
|
||||
let match;
|
||||
if (next != 35 /* '#' */ || !(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("TagLink", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const WikiMarkdown = commonmark.configure([WikiLink, AtMention, TagLink, {
|
||||
props: [
|
||||
styleTags({
|
||||
WikiLink: ct.WikiLinkTag,
|
||||
AtMention: ct.MentionTag,
|
||||
TagLink: ct.TagTag,
|
||||
})
|
||||
]
|
||||
}]);
|
||||
/// Language support for [GFM](https://github.github.com/gfm/) plus
|
||||
/// subscript, superscript, and emoji syntax.
|
||||
export default mkLang(WikiMarkdown);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { styleTags } from '@codemirror/highlight';
|
||||
import { MarkdownConfig } from "@lezer/markdown";
|
||||
import { commonmark, mkLang } from "./markdown/markdown";
|
||||
import * as ct from './customtags';
|
||||
|
||||
const WikiLink: MarkdownConfig = {
|
||||
defineNodes: ["WikiLink"],
|
||||
parseInline: [{
|
||||
name: "WikiLink",
|
||||
parse(cx, next, pos) {
|
||||
let match: RegExpMatchArray | null;
|
||||
if (next != 91 /* '[' */ || !(match = /^\[[^\]]+\]\]/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("WikiLink", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const AtMention: MarkdownConfig = {
|
||||
defineNodes: ["AtMention"],
|
||||
parseInline: [{
|
||||
name: "AtMention",
|
||||
parse(cx, next, pos) {
|
||||
let match: RegExpMatchArray | null;
|
||||
if (next != 64 /* '@' */ || !(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("AtMention", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const TagLink: MarkdownConfig = {
|
||||
defineNodes: ["TagLink"],
|
||||
parseInline: [{
|
||||
name: "TagLink",
|
||||
parse(cx, next, pos) {
|
||||
let match: RegExpMatchArray | null;
|
||||
if (next != 35 /* '#' */ || !(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))) {
|
||||
return -1;
|
||||
}
|
||||
return cx.addElement(cx.elt("TagLink", pos, pos + 1 + match[0].length));
|
||||
},
|
||||
after: "Emphasis"
|
||||
}]
|
||||
};
|
||||
const WikiMarkdown = commonmark.configure([WikiLink, AtMention, TagLink, {
|
||||
props: [
|
||||
styleTags({
|
||||
WikiLink: ct.WikiLinkTag,
|
||||
AtMention: ct.MentionTag,
|
||||
TagLink: ct.TagTag,
|
||||
})
|
||||
]
|
||||
}]);
|
||||
/// Language support for [GFM](https://github.github.com/gfm/) plus
|
||||
/// subscript, superscript, and emoji syntax.
|
||||
|
||||
export default mkLang(WikiMarkdown);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HighlightStyle, tags as t } from '@codemirror/highlight';
|
||||
import * as ct from './customtags';
|
||||
export default HighlightStyle.define([
|
||||
{ tag: t.heading1, class: "h1" },
|
||||
{ tag: t.heading2, class: "h2" },
|
||||
{ tag: t.link, class: "link" },
|
||||
{ tag: t.meta, class: "meta" },
|
||||
{ tag: t.quote, class: "quote" },
|
||||
{ tag: t.monospace, class: "code" },
|
||||
{ tag: t.url, class: "url" },
|
||||
{ tag: ct.WikiLinkTag, class: "wiki-link" },
|
||||
{ tag: ct.TagTag, class: "tag" },
|
||||
{ tag: ct.MentionTag, class: "mention" },
|
||||
{ tag: t.emphasis, class: "emphasis" },
|
||||
{ tag: t.strong, class: "strong" },
|
||||
{ tag: t.atom, class: "atom" },
|
||||
{ tag: t.bool, class: "bool" },
|
||||
{ tag: t.url, class: "url" },
|
||||
{ tag: t.inserted, class: "inserted" },
|
||||
{ tag: t.deleted, class: "deleted" },
|
||||
{ tag: t.literal, class: "literal" },
|
||||
{ tag: t.list, class: "list" },
|
||||
{ tag: t.definition, class: "li" },
|
||||
{ tag: t.string, class: "string" },
|
||||
{ tag: t.number, class: "number" },
|
||||
{ tag: [t.regexp, t.escape, t.special(t.string)], class: "string2" },
|
||||
{ tag: t.variableName, class: "variableName" },
|
||||
{ tag: t.comment, class: "comment" },
|
||||
{ tag: t.invalid, class: "invalid" },
|
||||
{ tag: t.punctuation, class: "punctuation" }
|
||||
]);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HighlightStyle, tags as t } from '@codemirror/highlight';
|
||||
import * as ct from './customtags';
|
||||
|
||||
export default HighlightStyle.define([
|
||||
{ tag: t.heading1, class: "h1" },
|
||||
{ tag: t.heading2, class: "h2" },
|
||||
{ tag: t.link, class: "link" },
|
||||
{ tag: t.meta, class: "meta" },
|
||||
{ tag: t.quote, class: "quote" },
|
||||
{ tag: t.monospace, class: "code" },
|
||||
{ tag: t.url, class: "url" },
|
||||
{ tag: ct.WikiLinkTag, class: "wiki-link" },
|
||||
{ tag: ct.TagTag, class: "tag" },
|
||||
{ tag: ct.MentionTag, class: "mention" },
|
||||
{ tag: t.emphasis, class: "emphasis" },
|
||||
{ tag: t.strong, class: "strong" },
|
||||
{ tag: t.atom, class: "atom" },
|
||||
{ tag: t.bool, class: "bool" },
|
||||
{ tag: t.url, class: "url" },
|
||||
{ tag: t.inserted, class: "inserted" },
|
||||
{ tag: t.deleted, class: "deleted" },
|
||||
{ tag: t.literal, class: "literal" },
|
||||
{ tag: t.list, class: "list" },
|
||||
{ tag: t.definition, class: "li" },
|
||||
{ tag: t.string, class: "string" },
|
||||
{ tag: t.number, class: "number" },
|
||||
{ tag: [t.regexp, t.escape, t.special(t.string)], class: "string2" },
|
||||
{ tag: t.variableName, class: "variableName" },
|
||||
{ tag: t.comment, class: "comment" },
|
||||
{ tag: t.invalid, class: "invalid" },
|
||||
{ tag: t.punctuation, class: "punctuation" }
|
||||
]);
|
||||
@@ -0,0 +1,132 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#top {
|
||||
height: 40px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#bottom {
|
||||
height: 40px;
|
||||
background-color: #eee;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#editor {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ident: 18px;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: var(--ident);
|
||||
}
|
||||
|
||||
.cm-editor .cm-content {
|
||||
font-family: "Menlo";
|
||||
margin: 25px;
|
||||
}
|
||||
|
||||
.cm-editor .cm-selectionBackground {
|
||||
background-color: #d7e1f6 !important;
|
||||
}
|
||||
|
||||
.cm-editor .h1 {
|
||||
font-size: 1.5em;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cm-editor .cm-line.line-h1 {
|
||||
display: block;
|
||||
background-color: rgba(0, 15, 52, 0.6);
|
||||
}
|
||||
|
||||
.cm-editor .h1.meta {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cm-editor .h2 {
|
||||
font-size: 1.2em;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cm-editor .cm-line.line-h2 {
|
||||
display: block;
|
||||
background-color: rgba(0, 15, 52, 0.6);
|
||||
}
|
||||
|
||||
.cm-editor .h2.meta {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cm-editor .line-code {
|
||||
background-color: #efefef;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
.cm-editor .line-fenced-code {
|
||||
background-color: #efefef;
|
||||
}
|
||||
|
||||
.cm-editor .meta {
|
||||
color: #520130;
|
||||
}
|
||||
|
||||
.cm-editor .line-blockquote {
|
||||
background-color: #eee;
|
||||
color: #676767;
|
||||
text-indent: calc(-1 * (var(--ident) + 3px));
|
||||
padding-left: var(--ident);
|
||||
}
|
||||
|
||||
.cm-editor .emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cm-editor .strong {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.cm-editor .link:not(.meta, .url) {
|
||||
color: #0330cb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cm-editor .link.url {
|
||||
color: #7e7d7d;
|
||||
}
|
||||
|
||||
.cm-editor .wiki-link {
|
||||
color: #0330cb;
|
||||
/*text-decoration: underline;*/
|
||||
}
|
||||
|
||||
.cm-editor .mention {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cm-editor .tag {
|
||||
color: #8d8d8d;
|
||||
}
|
||||
|
||||
.cm-editor .line-li {
|
||||
text-indent: calc(-1 * var(--ident) - 3px);
|
||||
margin-left: var(--ident);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"strict": true ,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
+1915
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user