Live Preview (#119)

Live preview mode is here
This commit is contained in:
Zef Hemel
2022-11-18 16:04:37 +01:00
committed by GitHub
parent c9713bf52b
commit 24c17a793f
42 changed files with 1502 additions and 183 deletions
+74
View File
@@ -0,0 +1,74 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
function hideNodes(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(view.state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
widgets.push(
Decoration.line({
class: "sb-line-hr",
}).range(node.from),
);
}
if (
node.name === "FrontMatterMarker"
) {
const parent = node.node.parent!;
if (!isCursorInRange(view.state, [parent.from, parent.to])) {
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(node.from),
);
}
}
if (
node.name === "CodeMark"
) {
const parent = node.node.parent!;
if (!isCursorInRange(view.state, [parent.from, parent.to])) {
widgets.push(
Decoration.line({
class: "sb-line-code-outside",
}).range(node.from),
);
}
}
},
});
return Decoration.set(widgets, true);
}
export const cleanBlockPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = hideNodes(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.decorations = hideNodes(update.view);
}
}
},
{ decorations: (v) => v.decorations },
);
+45
View File
@@ -0,0 +1,45 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
class BlockquotePlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = this.decorateLists(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.decorateLists(update.view);
}
}
private decorateLists(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (isCursorInRange(view.state, [from, to])) return;
if (type.name === "QuoteMark") {
widgets.push(invisibleDecoration.range(from, to));
widgets.push(
Decoration.line({ class: "sb-blockquote-outside" }).range(from),
);
}
},
});
return Decoration.set(widgets, true);
}
}
export const blockquotePlugin = ViewPlugin.fromClass(
BlockquotePlugin,
{
decorations: (v) => v.decorations,
},
);
+40
View File
@@ -0,0 +1,40 @@
import type { ClickEvent } from "../../plug-api/app_event.ts";
import type { Extension } from "../deps.ts";
import { Editor } from "../editor.tsx";
import { blockquotePlugin } from "./block_quote.ts";
import { directivePlugin } from "./directive.ts";
import { hideHeaderMarkPlugin, hideMarks } from "./hide_mark.ts";
import { cleanBlockPlugin } from "./block.ts";
import { goToLinkPlugin } from "./link.ts";
import { listBulletPlugin } from "./list.ts";
import { tablePlugin } from "./table.ts";
import { taskListPlugin } from "./task.ts";
import { cleanWikiLinkPlugin } from "./wiki_link.ts";
export function cleanModePlugins(editor: Editor) {
return [
goToLinkPlugin,
directivePlugin,
blockquotePlugin,
hideMarks(),
hideHeaderMarkPlugin,
cleanBlockPlugin,
taskListPlugin({
// TODO: Move this logic elsewhere?
onCheckboxClick: (pos) => {
const clickEvent: ClickEvent = {
page: editor.currentPage!,
altKey: false,
ctrlKey: false,
metaKey: false,
pos: pos,
};
// Propagate click event from checkbox
editor.dispatchAppEvent("page:click", clickEvent);
},
}),
listBulletPlugin,
tablePlugin,
cleanWikiLinkPlugin(),
] as Extension[];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { Extension, WebsocketProvider, Y, yCollab } from "./deps.ts";
import { Extension, WebsocketProvider, Y, yCollab } from "../deps.ts";
const userColors = [
{ color: "#30bced", light: "#30bced33" },
+70
View File
@@ -0,0 +1,70 @@
import {
Decoration,
DecorationSet,
EditorView,
syntaxTree,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { isCursorInRange } from "./util.ts";
function getDirectives(view: EditorView) {
const widgets: any[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from, to }) => {
if (type.name !== "CommentBlock") {
return;
}
const text = view.state.sliceDoc(from, to);
if (/<!--\s*#/.exec(text)) {
// Open directive
widgets.push(
Decoration.line({
class: "sb-directive-start",
}).range(from),
);
} else if (/<!--\s*\//.exec(text)) {
widgets.push(
Decoration.line({
class: "sb-directive-end",
}).range(from),
);
} else {
return;
}
if (!isCursorInRange(view.state, [from, to])) {
widgets.push(
Decoration.line({
class: "sb-directive-outside",
}).range(from),
);
}
},
});
}
return Decoration.set(widgets, true);
}
export const directivePlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = getDirectives(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
) {
this.decorations = getDirectives(update.view);
}
}
},
{ decorations: (v) => v.decorations },
);
@@ -1,7 +1,7 @@
import { EditorView, ViewPlugin, ViewUpdate } from "./deps.ts";
import { safeRun } from "../plugos/util.ts";
import { maximumAttachmentSize } from "../common/types.ts";
import { Editor } from "./editor.tsx";
import { EditorView, ViewPlugin, ViewUpdate } from "../deps.ts";
import { safeRun } from "../../plugos/util.ts";
import { maximumAttachmentSize } from "../../common/types.ts";
import { Editor } from "../editor.tsx";
// We use turndown to convert HTML to Markdown
import TurndownService from "https://cdn.skypack.dev/turndown@7.1.1";
+162
View File
@@ -0,0 +1,162 @@
// Forked from https://codeberg.org/retronav/ixora
// Original author: Pranav Karawale
// License: Apache License 2.0.
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
checkRangeOverlap,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
/**
* These types contain markers as child elements that can be hidden.
*/
const typesWithMarks = [
"Emphasis",
"StrongEmphasis",
"InlineCode",
"Highlight",
"Strikethrough",
"CommandLink",
];
/**
* The elements which are used as marks.
*/
const markTypes = [
"EmphasisMark",
"CodeMark",
"HighlightMark",
"StrikethroughMark",
"CommandLinkMark",
];
/**
* Plugin to hide marks when the they are not in the editor selection.
*/
class HideMarkPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.compute(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.compute(update.view);
}
}
compute(view: EditorView): DecorationSet {
const widgets: any[] = [];
let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (typesWithMarks.includes(type.name)) {
// There can be a possibility that the current node is a
// child eg. a bold node in a emphasis node, so check
// for that or else save the node range
if (
parentRange &&
checkRangeOverlap([from, to], parentRange)
) {
return;
} else parentRange = [from, to];
if (isCursorInRange(view.state, [from, to])) return;
const innerTree = node.toTree();
innerTree.iterate({
enter({ type, from: markFrom, to: markTo }) {
// Check for mark types and push the replace
// decoration
if (!markTypes.includes(type.name)) return;
widgets.push(
invisibleDecoration.range(
from + markFrom,
from + markTo,
),
);
},
});
}
},
});
return Decoration.set(widgets, true);
}
}
/**
* Ixora hide marks plugin.
*
* This plugin allows to:
* - Hide marks when they are not in the editor selection.
*/
export const hideMarks = () => [
ViewPlugin.fromClass(HideMarkPlugin, {
decorations: (v) => v.decorations,
}),
];
// HEADINGS
class HideHeaderMarkPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.hideHeaderMark(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.hideHeaderMark(update.view);
}
}
/**
* Function to decide if to insert a decoration to hide the header mark
* @param view - Editor view
* @returns The `Decoration`s that hide the header marks
*/
private hideHeaderMark(view: EditorView) {
const widgets: any[] = [];
const ranges = view.state.selection.ranges;
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
// Get the active line
const line = view.lineBlockAt(from);
// If any cursor overlaps with the heading line, skip
const cursorOverlaps = ranges.some(({ from, to }) =>
checkRangeOverlap([from, to], [line.from, line.to])
);
if (cursorOverlaps && type.name === "HeaderMark") {
widgets.push(
Decoration.line({ class: "sb-header-inside" }).range(from),
);
return;
} else if (cursorOverlaps) {
return;
}
if (
type.name === "HeaderMark" &&
// Setext heading's horizontal lines are not hidden.
/[#]/.test(view.state.sliceDoc(from, to))
) {
const dec = Decoration.replace({});
widgets.push(dec.range(from, to + 1));
}
},
});
return Decoration.set(widgets, true);
}
}
/**
* Plugin to hide the header mark.
*
* The header mark will not be hidden when:
* - The cursor is on the active line
* - The mark is on a line which is in the current selection
*/
export const hideHeaderMarkPlugin = ViewPlugin.fromClass(HideHeaderMarkPlugin, {
decorations: (v) => v.decorations,
});
@@ -1,13 +1,14 @@
import { syntaxTree } from "./deps.ts";
import { Range } from "./deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
Range,
syntaxTree,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "./deps.ts";
} from "../deps.ts";
import { invisibleDecoration, isCursorInRange } from "./util.ts";
class InlineImageWidget extends WidgetType {
constructor(readonly url: string, readonly title: string) {
@@ -47,6 +48,12 @@ const inlineImages = (view: EditorView) => {
return;
}
if (
!isCursorInRange(view.state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
const imageRexexResult = imageRegex.exec(
view.state.sliceDoc(node.from, node.to),
);
@@ -56,10 +63,11 @@ const inlineImages = (view: EditorView) => {
const url = imageRexexResult.groups.url;
const title = imageRexexResult.groups.title;
const deco = Decoration.widget({
widget: new InlineImageWidget(url, title),
});
widgets.push(deco.range(node.to));
widgets.push(
Decoration.widget({
widget: new InlineImageWidget(url, title),
}).range(node.to),
);
},
});
}
@@ -1,13 +1,12 @@
import { syntaxTree } from "../common/deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
Range,
syntaxTree,
ViewPlugin,
ViewUpdate,
} from "../common/deps.ts";
import { Range } from "./deps.ts";
} from "../deps.ts";
interface WrapElement {
selector: string;
+62
View File
@@ -0,0 +1,62 @@
// Forked from https://codeberg.org/retronav/ixora
// Original author: Pranav Karawale
// License: Apache License 2.0.
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
checkRangeOverlap,
invisibleDecoration,
iterateTreeInVisibleRanges,
} from "./util.ts";
function getLinkAnchor(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (type.name !== "URL") return;
const parent = node.parent;
const blackListedParents = ["Image"];
if (parent && !blackListedParents.includes(parent.name)) {
const marks = parent.getChildren("LinkMark");
const ranges = view.state.selection.ranges;
const cursorOverlaps = ranges.some(({ from, to }) =>
checkRangeOverlap([from, to], [parent.from, parent.to])
);
if (!cursorOverlaps) {
widgets.push(
...marks.map(({ from, to }) => invisibleDecoration.range(from, to)),
invisibleDecoration.range(from, to),
);
}
}
},
});
return Decoration.set(widgets, true);
}
export const goToLinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = getLinkAnchor(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
) {
this.decorations = getLinkAnchor(update.view);
}
}
},
{ decorations: (v) => v.decorations },
);
+66
View File
@@ -0,0 +1,66 @@
// Forked from https://codeberg.org/retronav/ixora
// Original author: Pranav Karawale
// License: Apache License 2.0.
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "../deps.ts";
import { isCursorInRange, iterateTreeInVisibleRanges } from "./util.ts";
const bulletListMarkerRE = /^[-+*]/;
/**
* Plugin to add custom list bullet mark.
*/
class ListBulletPlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = this.decorateLists(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.decorateLists(update.view);
}
}
private decorateLists(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (isCursorInRange(view.state, [from, to])) return;
if (type.name === "ListMark") {
const listMark = view.state.sliceDoc(from, to);
if (bulletListMarkerRE.test(listMark)) {
const dec = Decoration.replace({
widget: new ListBulletWidget(listMark),
});
widgets.push(dec.range(from, to));
}
}
},
});
return Decoration.set(widgets, true);
}
}
export const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations,
});
/**
* Widget to render list bullet mark.
*/
class ListBulletWidget extends WidgetType {
constructor(readonly bullet: string) {
super();
}
toDOM(): HTMLElement {
const listBullet = document.createElement("span");
listBullet.textContent = this.bullet;
listBullet.className = "cm-list-bullet";
return listBullet;
}
}
@@ -1,5 +1,4 @@
import { KeyBinding } from "./deps.ts";
import { syntaxTree } from "../common/deps.ts";
import { KeyBinding, syntaxTree } from "../deps.ts";
const straightQuoteContexts = [
"CommentBlock",
+107
View File
@@ -0,0 +1,107 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "../deps.ts";
import {
editorLines,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { ParseTree } from "$sb/lib/tree.ts";
import { lezerToParseTree } from "../../common/parse_tree.ts";
class TableViewWidget extends WidgetType {
constructor(
readonly pos: number,
readonly editorView: EditorView,
readonly t: ParseTree,
) {
super();
}
toDOM(): HTMLElement {
const dom = document.createElement("span");
dom.classList.add("sb-table-widget");
dom.addEventListener("click", (e) => {
// Pulling data-pos to put the cursor in the right place, falling back
// to the start of the table.
const dataAttributes = (e.target as any).dataset;
this.editorView.dispatch({
selection: {
anchor: dataAttributes.pos ? +dataAttributes.pos : this.pos,
},
});
});
dom.innerHTML = renderMarkdownToHtml(this.t, {
// Annotate every element with its position so we can use it to put
// the cursor there when the user clicks on the table.
annotationPositions: true,
});
return dom;
}
}
class TablePlugin {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = this.decorateLists(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.decorateLists(update.view);
}
}
private decorateLists(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter: (node) => {
const { from, to, name } = node;
if (name !== "Table") return;
if (isCursorInRange(view.state, [from, to])) return;
const lines = editorLines(view, from, to);
const firstLine = lines[0], lastLine = lines[lines.length - 1];
// In case of doubt, back out
if (!firstLine || !lastLine) return;
widgets.push(invisibleDecoration.range(firstLine.from, firstLine.to));
widgets.push(invisibleDecoration.range(lastLine.from, lastLine.to));
lines.slice(1, lines.length - 1).forEach((line) => {
widgets.push(
Decoration.line({ class: "sb-line-table-outside" }).range(
line.from,
),
);
});
const text = view.state.sliceDoc(0, to);
widgets.push(
Decoration.widget({
widget: new TableViewWidget(
from,
view,
lezerToParseTree(text, node.node),
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
}
}
export const tablePlugin = ViewPlugin.fromClass(
TablePlugin,
{
decorations: (v) => v.decorations,
},
);
+106
View File
@@ -0,0 +1,106 @@
import {
Decoration,
DecorationSet,
EditorView,
NodeType,
SyntaxNodeRef,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "../deps.ts";
import { isCursorInRange, iterateTreeInVisibleRanges } from "./util.ts";
// TODO: Find a nicer way to inject this on task handler into the class
function TaskListsPluginFactory(onCheckboxClick: (pos: number) => void) {
return class TaskListsPlugin {
decorations: DecorationSet = Decoration.none;
constructor(
view: EditorView,
) {
this.decorations = this.addCheckboxes(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.addCheckboxes(update.view);
}
}
addCheckboxes(view: EditorView) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
enter: this.iterateTree(view, widgets),
});
return Decoration.set(widgets, true);
}
private iterateTree(view: EditorView, widgets: any[]) {
return ({ type, from, to, node }: SyntaxNodeRef) => {
if (type.name !== "Task") return;
let checked = false;
// Iterate inside the task node to find the checkbox
node.toTree().iterate({
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
});
if (checked) {
widgets.push(
Decoration.mark({
tagName: "span",
class: "cm-task-checked",
}).range(from, to),
);
}
function iterateInner(type: NodeType, nfrom: number, nto: number) {
if (type.name !== "TaskMarker") return;
if (isCursorInRange(view.state, [from + nfrom, from + nto])) return;
const checkbox = view.state.sliceDoc(from + nfrom, from + nto);
// Checkbox is checked if it has a 'x' in between the []
if ("xX".includes(checkbox[1])) checked = true;
const dec = Decoration.replace({
widget: new CheckboxWidget(
checked,
from + nfrom + 1,
onCheckboxClick,
),
});
widgets.push(dec.range(from + nfrom, from + nto));
}
};
}
};
}
/**
* Widget to render checkbox for a task list item.
*/
class CheckboxWidget extends WidgetType {
constructor(
public checked: boolean,
readonly pos: number,
readonly clickCallback: (pos: number) => void,
) {
super();
}
toDOM(_view: EditorView): HTMLElement {
const wrap = document.createElement("span");
wrap.classList.add("sb-checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this.checked;
checkbox.addEventListener("click", (e) => {
// Let the click handler handle this
e.stopPropagation();
this.clickCallback(this.pos);
});
wrap.appendChild(checkbox);
return wrap;
}
}
export function taskListPlugin(
{ onCheckboxClick }: { onCheckboxClick: (pos: number) => void },
) {
return ViewPlugin.fromClass(TaskListsPluginFactory(onCheckboxClick), {
decorations: (v) => v.decorations,
});
}
+99
View File
@@ -0,0 +1,99 @@
// Forked from https://codeberg.org/retronav/ixora
// Original author: Pranav Karawale
// License: Apache License 2.0.
import {
Decoration,
EditorState,
EditorView,
foldedRanges,
SyntaxNodeRef,
syntaxTree,
} from "../deps.ts";
/**
* Check if two ranges overlap
* Based on the visual diagram on https://stackoverflow.com/a/25369187
* @param range1 - Range 1
* @param range2 - Range 2
* @returns True if the ranges overlap
*/
export function checkRangeOverlap(
range1: [number, number],
range2: [number, number],
) {
return range1[0] <= range2[1] && range2[0] <= range1[1];
}
/**
* Check if a range is inside another range
* @param parent - Parent (bigger) range
* @param child - Child (smaller) range
* @returns True if child is inside parent
*/
export function checkRangeSubset(
parent: [number, number],
child: [number, number],
) {
return child[0] >= parent[0] && child[1] <= parent[1];
}
/**
* Check if any of the editor cursors is in the given range
* @param state - Editor state
* @param range - Range to check
* @returns True if the cursor is in the range
*/
export function isCursorInRange(state: EditorState, range: [number, number]) {
return state.selection.ranges.some((selection) =>
checkRangeOverlap(range, [selection.from, selection.to])
);
}
/**
* Decoration to simply hide anything.
*/
export const invisibleDecoration = Decoration.replace({});
export function iterateTreeInVisibleRanges(
view: EditorView,
iterateFns: {
enter(node: SyntaxNodeRef): boolean | void;
leave?(node: SyntaxNodeRef): void;
},
) {
// for (const { from, to } of view.visibleRanges) {
// syntaxTree(view.state).iterate({ ...iterateFns, from, to });
// }
syntaxTree(view.state).iterate(iterateFns);
}
/**
* Returns the lines of the editor that are in the given range and not folded.
* This function is of use when you need to get the lines of a particular
* block node and add line decorations to each line of it.
*
* @param view - Editor view
* @param from - Start of the range
* @param to - End of the range
* @returns A list of line blocks that are in the range
*/
export function editorLines(view: EditorView, from: number, to: number) {
let lines = view.viewportLineBlocks.filter((block) =>
// Keep lines that are in the range
checkRangeOverlap([block.from, block.to], [from, to])
);
const folded = foldedRanges(view.state).iter();
while (folded.value) {
lines = lines.filter(
(line) =>
!checkRangeOverlap(
[folded.from, folded.to],
[line.from, line.to],
),
);
folded.next();
}
return lines;
}
+76
View File
@@ -0,0 +1,76 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
/**
* Plugin to hide path prefix when the cursor is not inside.
*/
class CleanWikiLinkPlugin {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.compute(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.compute(update.view);
}
}
compute(view: EditorView): DecorationSet {
const widgets: any[] = [];
// let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (type.name === "WikiLinkPage") {
// Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
if (isCursorInRange(view.state, [from - 2, to + 2])) {
return;
}
// Add decoration to hide the prefix [[
widgets.push(
invisibleDecoration.range(
from - 2,
from,
),
);
// Add decoration to hide the postfix [[
widgets.push(
invisibleDecoration.range(
to,
to + 2,
),
);
// Now check if there's a "/" inside
const text = view.state.sliceDoc(from, to);
if (text.indexOf("/") === -1) {
return;
}
// Add a inivisible decoration to hide the path prefix
widgets.push(
invisibleDecoration.range(
from,
from + text.lastIndexOf("/") + 1,
),
);
}
},
});
return Decoration.set(widgets, true);
}
}
export const cleanWikiLinkPlugin = () => [
ViewPlugin.fromClass(CleanWikiLinkPlugin, {
decorations: (v) => v.decorations,
}),
];
+5 -4
View File
@@ -135,7 +135,7 @@ export function FilterList({
searchBoxRef.current!.focus();
}
}}
onKeyDown={(e) => {
onKeyUp={(e) => {
// console.log("Key up", / e);
if (onKeyPress) {
onKeyPress(e.key, text);
@@ -178,12 +178,13 @@ export function FilterList({
}
break;
default:
setTimeout(() => {
updateFilter((e.target as any).value);
});
updateFilter((e.target as any).value);
}
e.stopPropagation();
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
+45 -48
View File
@@ -1,14 +1,13 @@
// import { Fragment, h } from "../deps.ts";
import {
faHome,
faMoon,
faRunning,
faSun,
} from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0";
import { FontAwesomeIcon } from "../deps.ts";
import { FontAwesomeIcon, useRef } from "../deps.ts";
import { ComponentChildren, useState } from "../deps.ts";
import {
ComponentChildren,
IconDefinition,
useEffect,
useState,
} from "../deps.ts";
import { Notification } from "../types.ts";
import { isMacLike } from "../../common/util.ts";
@@ -19,15 +18,19 @@ function prettyName(s: string | undefined): string {
return s.replaceAll("/", " / ");
}
export type ActionButton = {
icon: IconDefinition;
description: string;
callback: () => void;
};
export function TopBar({
pageName,
unsavedChanges,
isLoading,
notifications,
onClick,
onThemeClick,
onHomeClick,
onActionClick,
onRename,
actionButtons,
lhs,
rhs,
}: {
@@ -35,19 +38,17 @@ export function TopBar({
unsavedChanges: boolean;
isLoading: boolean;
notifications: Notification[];
onClick: () => void;
onThemeClick: () => void;
onHomeClick: () => void;
onActionClick: () => void;
onRename: (newName: string) => void;
actionButtons: ActionButton[];
lhs?: ComponentChildren;
rhs?: ComponentChildren;
}) {
const [theme, setTheme] = useState<string>(localStorage.theme ?? "light");
const inputRef = useRef<HTMLInputElement>(null);
const isMac = isMacLike();
return (
<div id="sb-top" onClick={onClick}>
<div id="sb-top">
{lhs}
<div className="main">
<div className="inner">
@@ -60,7 +61,21 @@ export function TopBar({
: "sb-saved"
}`}
>
{prettyName(pageName)}
<input
type="text"
ref={inputRef}
value={pageName}
className="sb-edit-page-name"
onKeyDown={(e) => {
console.log("Key press", e);
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
const newName = (e.target as any).value;
onRename(newName);
}
}}
/>
</span>
{notifications.length > 0 && (
<div className="sb-notifications">
@@ -75,35 +90,17 @@ export function TopBar({
</div>
)}
<div className="sb-actions">
<button
onClick={(e) => {
onHomeClick();
e.stopPropagation();
}}
title="Navigate to the 'index' page"
>
<FontAwesomeIcon icon={faHome} />
</button>
<button
onClick={(e) => {
onActionClick();
e.stopPropagation();
}}
title={"Open the command palette (" + (isMac ? "Cmd" : "Ctrl") +
"+/)"}
>
<FontAwesomeIcon icon={faRunning} />
</button>
<button
onClick={(e) => {
onThemeClick();
setTheme(localStorage.theme ?? "light");
e.stopPropagation();
}}
title="Toggle theme"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
</button>
{actionButtons.map((actionButton) => (
<button
onClick={(e) => {
actionButton.callback();
e.stopPropagation();
}}
title={actionButton.description}
>
<FontAwesomeIcon icon={actionButton.icon} />
</button>
))}
</div>
</div>
</div>
+93 -50
View File
@@ -1,3 +1,4 @@
// Third party web dependencies
import {
preactRender,
useEffect,
@@ -5,6 +6,16 @@ import {
yUndoManagerKeymap,
} from "./deps.ts";
// Iconography
import {
faFolderTree,
faHome,
faMoon,
faRunning,
faSun,
} from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0";
// Third-party dependencies
import {
autocompletion,
closeBrackets,
@@ -37,37 +48,29 @@ import {
ViewUpdate,
yamlLanguage,
} from "../common/deps.ts";
import { SilverBulletHooks } from "../common/manifest.ts";
// import { markdown } from "../common/_markdown/index.ts";
import { markdown } from "../common/deps.ts";
import { SilverBulletHooks } from "../common/manifest.ts";
import { markdown } from "../common/deps.ts";
import { loadMarkdownExtensions, MDExt } from "../common/markdown_ext.ts";
import buildMarkdown from "../common/parser.ts";
import { Space } from "../common/spaces/space.ts";
import { markdownSyscalls } from "../common/syscalls/markdown.ts";
import { FilterOption, PageMeta } from "../common/types.ts";
import { safeRun, throttle } from "../common/util.ts";
import { isMacLike, safeRun, throttle } from "../common/util.ts";
import { PathPageNavigator } from "./navigator.ts";
import reducer from "./reducer.ts";
// PlugOS Dependencies
import { createSandbox } from "../plugos/environments/webworker_sandbox.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { eventSyscalls } from "../plugos/syscalls/event.ts";
import sandboxSyscalls from "../plugos/syscalls/sandbox.ts";
import { System } from "../plugos/system.ts";
import { AppEvent, ClickEvent } from "../plug-api/app_event.ts";
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { PageNavigator } from "./components/page_navigator.tsx";
import { Panel } from "./components/panel.tsx";
import { TopBar } from "./components/top_bar.tsx";
import { attachmentExtension, pasteLinkExtension } from "./editor_paste.ts";
import { CommandHook } from "./hooks/command.ts";
import { SlashCommandHook } from "./hooks/slash_command.ts";
import { inlineImagesPlugin } from "./inline_image.ts";
import { lineWrapper } from "./line_wrapper.ts";
import { PathPageNavigator } from "./navigator.ts";
import reducer from "./reducer.ts";
import { smartQuoteKeymap } from "./smart_quotes.ts";
import customMarkdownStyle from "./style.ts";
// Syscalls
import { clientStoreSyscalls } from "./syscalls/clientStore.ts";
import { editorSyscalls } from "./syscalls/editor.ts";
import { fulltextSyscalls } from "./syscalls/fulltext.ts";
@@ -75,9 +78,32 @@ import { indexerSyscalls } from "./syscalls/index.ts";
import { spaceSyscalls } from "./syscalls/space.ts";
import { storeSyscalls } from "./syscalls/store.ts";
import { systemSyscalls } from "./syscalls/system.ts";
import { Action, AppViewState, initialViewState } from "./types.ts";
import assetSyscalls from "../plugos/syscalls/asset.ts";
import { CollabState } from "./collab.ts";
// State and state transitions
import { Action, AppViewState, initialViewState } from "./types.ts";
import type { AppEvent, ClickEvent } from "../plug-api/app_event.ts";
// UI Components
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { PageNavigator } from "./components/page_navigator.tsx";
import { Panel } from "./components/panel.tsx";
import { TopBar } from "./components/top_bar.tsx";
// CodeMirror plugins
import {
attachmentExtension,
pasteLinkExtension,
} from "./cm_plugins/editor_paste.ts";
import { inlineImagesPlugin } from "./cm_plugins/inline_image.ts";
import { lineWrapper } from "./cm_plugins/line_wrapper.ts";
import { smartQuoteKeymap } from "./cm_plugins/smart_quotes.ts";
import { cleanModePlugins } from "./cm_plugins/clean.ts";
import customMarkdownStyle from "./style.ts";
// Real-time collaboration
import { CollabState } from "./cm_plugins/collab.ts";
import { collabSyscalls } from "./syscalls/collab.ts";
class PageState {
@@ -437,11 +463,13 @@ export class Editor {
drawSelection(),
dropCursor(),
indentOnInput(),
...cleanModePlugins(this),
EditorView.lineWrapping,
lineWrapper([
{ selector: "ATXHeading1", class: "sb-line-h1" },
{ selector: "ATXHeading2", class: "sb-line-h2" },
{ selector: "ATXHeading3", class: "sb-line-h3" },
{ selector: "ATXHeading4", class: "sb-line-h4" },
{ selector: "ListItem", class: "sb-line-li", nesting: true },
{ selector: "Blockquote", class: "sb-line-blockquote" },
{ selector: "Task", class: "sb-line-task" },
@@ -483,23 +511,6 @@ export class Editor {
return true;
},
},
{
key: "Ctrl-l",
mac: "Cmd-l",
run: (): boolean => {
this.editorView?.dispatch({
effects: [
EditorView.scrollIntoView(
this.editorView.state.selection.main.anchor,
{
y: "center",
},
),
],
});
return true;
},
},
]),
EditorView.domEventHandlers({
@@ -820,20 +831,52 @@ export class Editor {
notifications={viewState.notifications}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
onClick={() => {
dispatch({ type: "start-navigate" });
}}
onThemeClick={() => {
if (localStorage.theme === "dark") localStorage.theme = "light";
else localStorage.theme = "dark";
document.documentElement.dataset.theme = localStorage.theme;
}}
onHomeClick={() => {
editor.navigate("");
}}
onActionClick={() => {
dispatch({ type: "show-palette" });
onRename={(newName) => {
console.log("Now renaming page to...", newName);
editor.system.loadedPlugs.get("core")!.invoke(
"renamePage",
[newName],
).then(() => {
editor.focus();
}).catch(console.error);
}}
actionButtons={[
{
icon: faHome,
description: `Go home (Alt-h)`,
callback: () => {
editor.navigate("");
},
},
{
icon: faFolderTree,
description: `Open page (${isMacLike() ? "Cmd-k" : "Ctrl-k"})`,
callback: () => {
dispatch({ type: "start-navigate" });
},
},
{
icon: faRunning,
description: `Run command (${isMacLike() ? "Cmd-/" : "Ctrl-/"})`,
callback: () => {
dispatch({ type: "show-palette" });
},
},
{
icon: localStorage.theme === "dark" ? faSun : faMoon,
description: "Toggle dark mode",
callback: () => {
if (localStorage.theme === "dark") {
localStorage.theme = "light";
} else {
localStorage.theme = "dark";
}
document.documentElement.dataset.theme = localStorage.theme;
// Trigger rerender: TERRIBLE IMPLEMENTATION
dispatch({ type: "page-saved" });
},
},
]}
rhs={!!viewState.panels.rhs.mode && (
<div
className="panel"
+2
View File
@@ -8,6 +8,7 @@ import { syntaxTree } from "../deps.ts";
export type SlashCommandDef = {
name: string;
description?: string;
boost?: number;
};
export type AppSlashCommand = {
@@ -71,6 +72,7 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
options.push({
label: def.slashCommand.name,
detail: def.slashCommand.description,
boost: def.slashCommand.boost,
apply: () => {
// Delete slash command part
this.editor.editorView?.dispatch({
+2
View File
@@ -16,6 +16,8 @@ export default function highlightStyles(mdExtension: MDExt[]) {
{ tag: t.url, class: "sb-url" },
{ tag: ct.WikiLinkTag, class: "sb-wiki-link" },
{ tag: ct.WikiLinkPageTag, class: "sb-wiki-link-page" },
{ tag: ct.CommandLinkTag, class: "sb-command-link" },
{ tag: ct.CommandLinkNameTag, class: "sb-command-link-name" },
{ tag: ct.TaskTag, class: "sb-task" },
{ tag: ct.TaskMarkerTag, class: "sb-task-marker" },
{ tag: ct.CodeInfoTag, class: "sb-code-info" },
+134 -3
View File
@@ -22,19 +22,27 @@
}
// Indentation of follow-up lines
@mixin lineOverflow($baseIndent) {
@mixin lineOverflow($baseIndent, $bulletIndent: 0) {
text-indent: -1 * ($baseIndent + 2ch);
padding-left: $baseIndent + 2ch;
&.sb-line-task {
text-indent: -1 * ($baseIndent + 6ch);
padding-left: $baseIndent + 6ch;
text-indent: -1 * ($baseIndent + 5ch);
padding-left: $baseIndent + 5ch;
.cm-list-bullet::after {
left: ($baseIndent + 5ch);
}
}
&.sb-line-blockquote {
text-indent: -1 * ($baseIndent + 4ch);
padding-left: $baseIndent + 4ch;
}
.cm-list-bullet::after {
left: ($baseIndent + $bulletIndent + 2ch);
}
}
.sb-line-ul {
@@ -81,8 +89,131 @@
}
}
.sb-line-blockquote {
&.sb-line-li-1 {
@include lineOverflow(-1, 2);
}
&.sb-line-li-1.sb-line-li-2 {
@include lineOverflow(1, 2);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(4);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
@include lineOverflow(6);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
@include lineOverflow(8);
}
}
.sb-line-comment {
text-indent: -1 * 3ch;
padding-left: 3ch;
}
.cm-list-bullet {
position: relative;
visibility: hidden;
}
.cm-task-checked {
text-decoration: line-through !important;
}
.sb-checkbox > input[type=checkbox] {
width: 3ch;
}
.cm-list-bullet::after {
visibility: visible;
position: absolute;
color: rgb(150, 150, 150);
content: "\2022"; /* U+2022 BULLET */
}
.sb-directive-start .sb-comment, .sb-directive-end .sb-comment {
position: relative;
left: -12px;
}
.sb-directive-start::before {
content: "#";
color: gray;
border: 1px solid gray;
border-radius: 5px;
font-size: 62%;
padding: 2px;
position: relative;
left: -20px;
}
.sb-directive-outside {
opacity: 0.4;
}
.sb-line-frontmatter-outside, .sb-line-code-outside {
display: none;
}
.sb-blockquote-outside {
text-indent: -1ch;
min-height: 1em;
}
.sb-line-table-outside {
display: none;
}
.sb-table-widget {
display: block;
font-weight: normal;
margin-bottom: -3rem;
table {
width: 100%;
border-spacing: 0;
}
thead tr {
background-color: #333;
color: #eee;
font-weight: bold;
}
th, td {
padding: 8px;
}
tbody tr:nth-of-type(even) {
background-color: #f3f3f3;
}
}
.sb-line-blockquote {
border-left: 1px solid rgb(74, 74, 74);
}
.sb-line-blockquote.sb-line-ul.sb-line-li > .sb-quote.sb-meta:first-child {
margin-left: -1ch;
}
.sb-directive-end::before {
content: "/";
border-radius: 5px;
color: gray;
border: 1px solid gray;
font-size: 62%;
padding: 2px;
position: relative;
left: -20px;
}
}
.cm-scroller {
// Give some breathing space at the bottom of the screen
padding-bottom: 20em;
}
+12
View File
@@ -82,6 +82,18 @@
display: block;
text-overflow: ellipsis;
}
input.sb-edit-page-name {
background: transparent;
font-weight: bold;
font-size: 28px;
white-space: nowrap;
text-align: left;
border: 0;
outline: none;
padding: 0;
width: 100%;
}
}
.sb-actions {
+45 -13
View File
@@ -37,11 +37,11 @@
background-color: rgb(255, 84, 84);
}
.sb-saved {
.sb-saved input {
color: #111;
}
.sb-unsaved {
.sb-unsaved input {
color: #5e5e5e;
}
@@ -128,19 +128,37 @@
}
}
.sb-header-inside.sb-line-h1 {
text-indent: -2ch;
}
.sb-header-inside.sb-line-h2 {
text-indent: -3ch;
}
.sb-header-inside.sb-line-h3 {
text-indent: -4ch;
}
.sb-header-inside.sb-line-h4 {
text-indent: -5ch;
}
.sb-line-h1,
.sb-line-h2,
.sb-line-h3 {
background-color: rgba(0, 30, 77, 0.5);
color: #fff;
.sb-line-h3,
.sb-line-h4 {
// background-color: rgba(0, 30, 77, 0.5);
color: #333;
font-weight: bold;
padding: 2px 2px !important;
}
.sb-line-h1 .sb-meta,
.sb-line-h2 .sb-meta,
.sb-line-h3 .sb-meta {
color: orange;
.sb-line-h3 .sb-meta,
.sb-line-h4 .sb-meta {
color: #a1a1a0;
}
.sb-line-h1 {
@@ -155,15 +173,22 @@
font-size: 1.1em;
}
.sb-line-h4 {
font-size: 1em;
}
.sb-hashtag {
color: blue;
}
.sb-line-hr {
border-top: rgb(76, 75, 75) solid 1px;
margin-top: 1em;
margin-bottom: -1em;
}
.sb-hr {
background-color: #f5f5f5;
line-height: 0.9em;
display: block;
color: #8d8a8a;
font-weight: bold;
}
.sb-naked-url {
@@ -175,7 +200,11 @@
color: #959595;
}
.sb-command-link {
.sb-command-link.sb-meta {
color: #959595;
}
.sb-command-link-name {
background-color: #e3dfdf;
cursor: pointer;
border-top: 1px solid silver;
@@ -320,7 +349,8 @@
}
.sb-task-marker {
background-color: #ddd;
color: #676767;
font-size: 91%;
}
.sb-line-comment {
@@ -392,4 +422,6 @@ html[data-theme="dark"] {
.sb-command-link {
background-color: #595959;
}
}
+6
View File
@@ -185,6 +185,12 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
): string | null => {
return prompt(message, defaultValue);
},
"editor.confirm": (
_ctx,
message: string,
): boolean => {
return confirm(message);
},
"editor.enableReadOnlyMode": (_ctx, enabled: boolean) => {
editor.viewDispatch({
type: "set-editor-ro",
+2
View File
@@ -20,6 +20,7 @@ export type PanelConfig = {
export type AppViewState = {
currentPage?: string;
editingPageName: boolean;
perm: EditorMode;
forcedROMode: boolean;
isLoading: boolean;
@@ -42,6 +43,7 @@ export type AppViewState = {
export const initialViewState: AppViewState = {
perm: "rw",
editingPageName: false,
forcedROMode: false,
isLoading: false,
showPageNavigator: false,