Cleanup, functional

This commit is contained in:
Zef Hemel
2022-02-21 13:25:41 +01:00
parent cbe0677f93
commit c9f4266d34
21 changed files with 237 additions and 451 deletions
-130
View File
@@ -1,130 +0,0 @@
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);
+51 -77
View File
@@ -24,19 +24,11 @@ import customMarkdownStyle from "./style";
import { FilterList } from "./components/filter";
import { NoteMeta, AppViewState, Action } from "./types";
import reducer from "./reducer";
const fs = new HttpFileSystem("http://localhost:2222/fs");
type NoteMeta = {
name: string;
};
type AppViewState = {
currentNote: string;
isSaved: boolean;
isFiltering: boolean;
allNotes: NoteMeta[];
};
const initialViewState = {
currentNote: "",
isSaved: false,
@@ -44,50 +36,6 @@ const initialViewState = {
allNotes: [],
};
type Action =
| { type: "loaded"; name: string }
| { type: "saved" }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| { type: "updated" }
| { type: "notes-list"; notes: NoteMeta[] };
function reducer(state: AppViewState, action: Action): AppViewState {
switch (action.type) {
case "loaded":
return {
...state,
currentNote: action.name,
isSaved: true,
};
case "saved":
return {
...state,
isSaved: true,
};
case "updated":
return {
...state,
isSaved: false,
};
case "start-navigate":
return {
...state,
isFiltering: true,
};
case "stop-navigate":
return {
...state,
isFiltering: false,
};
case "notes-list":
return {
...state,
allNotes: action.notes,
};
}
}
class Editor {
view: EditorView;
currentNote: string;
@@ -184,7 +132,6 @@ class Editor {
update(value: null, transaction: Transaction): null {
if (transaction.docChanged) {
console.log("Something changed");
this.dispatch({
type: "updated",
});
@@ -215,6 +162,10 @@ class Editor {
focus() {
this.view.focus();
}
navigate(name: string) {
location.hash = encodeURIComponent(name);
}
}
function TopBar({
@@ -223,28 +174,32 @@ function TopBar({
isFiltering,
allNotes,
onNavigate,
onClick,
}: {
currentNote: string;
isSaved: boolean;
isFiltering: boolean;
allNotes: NoteMeta[];
onNavigate: (note: string) => void;
onNavigate: (note: string | undefined) => void;
onClick: () => void;
}) {
return (
<div id="top">
<span className="current-note">{currentNote}</span>
{isSaved ? "" : "*"}
<div className="current-note" onClick={onClick}>
» {currentNote}
{isSaved ? "" : "*"}
</div>
{isFiltering ? (
{isFiltering && (
<FilterList
initialText=""
options={allNotes}
onSelect={(opt) => {
console.log("Selected", opt);
onNavigate(opt.name);
onNavigate(opt?.name);
}}
></FilterList>
) : null}
)}
</div>
);
}
@@ -260,13 +215,9 @@ function AppView() {
editor.focus();
// @ts-ignore
window.editor = editor;
fs.readNote("start").then((text) => {
editor!.load("start", text);
dispatch({
type: "loaded",
name: "start",
});
});
if (!location.hash) {
editor.navigate("start");
}
}, []);
useEffect(() => {
@@ -280,6 +231,30 @@ function AppView() {
.catch((e) => console.error(e));
}, []);
useEffect(() => {
function hashChange() {
const noteName = decodeURIComponent(location.hash.substring(1));
console.log("Now navigating to", noteName);
fs.readNote(noteName)
.then((text) => {
editor!.load(noteName, text);
dispatch({
type: "loaded",
name: noteName,
});
})
.catch((e) => {
console.error("Error loading note", e);
});
}
hashChange();
window.addEventListener("hashchange", hashChange);
return () => {
window.removeEventListener("hashchange", hashChange);
};
}, []);
return (
<>
<TopBar
@@ -287,16 +262,15 @@ function AppView() {
isSaved={appState.isSaved}
isFiltering={appState.isFiltering}
allNotes={appState.allNotes}
onClick={() => {
dispatch({ type: "start-navigate" });
}}
onNavigate={(note) => {
dispatch({ type: "stop-navigate" });
editor!.focus();
fs.readNote(note).then((text) => {
editor!.load(note, text);
dispatch({
type: "loaded",
name: note,
});
});
if (note) {
editor!.navigate(note);
}
}}
/>
<div id="editor" ref={editorRef}></div>
@@ -305,4 +279,4 @@ function AppView() {
);
}
ReactDOM.render(<AppView />, document.body);
ReactDOM.render(<AppView />, document.getElementById("root"));
-38
View File
@@ -1,38 +0,0 @@
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;
};
}
+51 -27
View File
@@ -2,16 +2,19 @@ import React, { useEffect, useRef, useState } from "react";
type Option = {
name: string;
hint?: string;
};
export function FilterList({
initialText,
options,
onSelect,
allowNew = false,
}: {
initialText: string;
options: Option[];
onSelect: (option: Option) => void;
onSelect: (option: Option | undefined) => void;
allowNew?: boolean;
}) {
const searchBoxRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState(initialText);
@@ -19,18 +22,23 @@ export function FilterList({
const [selectedOption, setSelectionOption] = useState(0);
const filter = (e: React.ChangeEvent<HTMLInputElement>) => {
const keyword = e.target.value.toLowerCase();
const originalPhrase = e.target.value;
const searchPhrase = originalPhrase.toLowerCase();
if (keyword) {
const results = options.filter((option) => {
return option.name.toLowerCase().indexOf(keyword) !== -1;
if (searchPhrase) {
let results = options.filter((option) => {
return option.name.toLowerCase().indexOf(searchPhrase) !== -1;
});
results.splice(0, 0, {
name: originalPhrase,
hint: "Create new",
});
setMatchingOptions(results);
} else {
setMatchingOptions(options);
}
setText(keyword);
setText(originalPhrase);
setSelectionOption(0);
};
@@ -38,10 +46,22 @@ export function FilterList({
searchBoxRef.current!.focus();
}, []);
useEffect(() => {
function closer() {
onSelect(undefined);
}
document.addEventListener("click", closer);
return () => {
console.log("Unsubscribing");
document.removeEventListener("click", closer);
};
}, []);
return (
<div className="filter-container">
<input
type="search"
type="text"
value={text}
ref={searchBoxRef}
onChange={filter}
@@ -60,32 +80,36 @@ export function FilterList({
onSelect(matchingOptions[selectedOption]);
e.preventDefault();
break;
case "Escape":
onSelect(undefined);
break;
}
}}
className="input"
placeholder="Filter"
placeholder=""
/>
<div className="result-list">
{matchingOptions && matchingOptions.length > 0 ? (
matchingOptions.map((option, idx) => (
<li
key={"" + idx}
className={selectedOption === idx ? "selected-option" : "option"}
onMouseOver={(e) => {
setSelectionOption(idx);
}}
onClick={(e) => {
onSelect(option);
e.preventDefault();
}}
>
<span className="user-name">{option.name}</span>
</li>
))
) : (
<h1>No results found!</h1>
)}
{matchingOptions && matchingOptions.length > 0
? matchingOptions.map((option, idx) => (
<div
key={"" + idx}
className={
selectedOption === idx ? "selected-option" : "option"
}
onMouseOver={(e) => {
setSelectionOption(idx);
}}
onClick={(e) => {
e.preventDefault();
onSelect(option);
}}
>
<span className="user-name">{option.name}</span>
{option.hint && <span className="hint">{option.hint}</span>}
</div>
))
: null}
</div>
</div>
);
-4
View File
@@ -1,4 +0,0 @@
import { Tag } from '@codemirror/highlight';
export const WikiLinkTag = Tag.define();
export const TagTag = Tag.define();
export const MentionTag = Tag.define();
-24
View File
@@ -1,24 +0,0 @@
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();
}
}
+2 -3
View File
@@ -1,7 +1,6 @@
export interface NoteMeta {
name: string;
}
import { NoteMeta } from "./types";
export interface FileSystem {
listNotes(): Promise<NoteMeta[]>;
+3 -1
View File
@@ -8,5 +8,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body></body>
<body>
<div id="root"></div>
</body>
</html>
-45
View File
@@ -1,45 +0,0 @@
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,
});
-57
View File
@@ -1,57 +0,0 @@
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);
+38
View File
@@ -0,0 +1,38 @@
import { Action, AppViewState } from "./types";
export default function reducer(state: AppViewState, action: Action): AppViewState {
console.log("Got action", action)
switch (action.type) {
case "loaded":
return {
...state,
currentNote: action.name,
isSaved: true,
};
case "saved":
return {
...state,
isSaved: true,
};
case "updated":
return {
...state,
isSaved: false,
};
case "start-navigate":
return {
...state,
isFiltering: true,
};
case "stop-navigate":
return {
...state,
isFiltering: false,
};
case "notes-list":
return {
...state,
allNotes: action.notes,
};
}
}
-31
View File
@@ -1,31 +0,0 @@
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" }
]);
+46 -9
View File
@@ -5,25 +5,31 @@ body {
padding: 0;
}
body {
display: flex;
flex-direction: column;
}
#top {
height: 40px;
background-color: #eee;
position: absolute;
top: 0;
left: 0;
right: 0;
}
#bottom {
height: 40px;
background-color: #eee;
margin: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
}
#editor {
flex-grow: 1;
width: 100%;
position: absolute;
top: 40px;
bottom: 40px;
left: 0;
right: 0;
overflow-y: hidden;
}
@@ -39,7 +45,7 @@ body {
.cm-editor .cm-content {
font-family: "Menlo";
margin: 25px;
margin: 5px;
}
.cm-editor .cm-selectionBackground {
@@ -142,15 +148,46 @@ reach-portal > div > div {
padding: 5px;
}
.current-note {
font-family: "Menlo";
margin-left: 10px;
margin-top: 10px;
font-weight: bold;
}
.filter-container {
font-family: "Menlo";
background-color: white;
display: block;
position: absolute;
left: 10px;
top: 10px;
right: 10px;
z-index: 1000;
border: #333 1px solid;
}
.filter-container input {
font-family: "Menlo";
width: 100%;
/* border: 1px #333 solid; */
border: 0;
border-bottom: 1px #333 dotted;
padding: 3px;
}
.filter-container .option,
.filter-container .selected-option {
padding: 3px 3px 3px 3px;
cursor: pointer;
}
.filter-container .selected-option {
background-color: #520130;
background-color: #b1b1b1;
}
.filter-container .hint {
float: right;
margin-right: 10px;
color: #333;
}
+20
View File
@@ -0,0 +1,20 @@
export type NoteMeta = {
name: string;
};
export type AppViewState = {
currentNote: string;
isSaved: boolean;
isFiltering: boolean;
allNotes: NoteMeta[];
};
export type Action =
| { type: "loaded"; name: string }
| { type: "saved" }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| { type: "updated" }
| { type: "notes-list"; notes: NoteMeta[] };