Live Preview (#119)

Live preview mode is here
demo
Zef Hemel 2022-11-18 16:04:37 +01:00 committed by GitHub
parent c9713bf52b
commit 24c17a793f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1502 additions and 183 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ data.db
publish-data.db
/index.json
.idea
deno.lock

View File

@ -1,5 +1,7 @@
import { Tag } from "./deps.ts";
export const CommandLinkTag = Tag.define();
export const CommandLinkNameTag = Tag.define();
export const WikiLinkTag = Tag.define();
export const WikiLinkPageTag = Tag.define();
export const CodeInfoTag = Tag.define();

View File

@ -41,7 +41,9 @@ export {
TaskList,
} from "@lezer/markdown";
export type { SyntaxNode, Tree } from "@lezer/common";
export { parseMixed } from "@lezer/common";
export type { NodeType, SyntaxNode, SyntaxNodeRef, Tree } from "@lezer/common";
export { searchKeymap } from "https://esm.sh/@codemirror/search@6.2.2?external=@codemirror/state,@codemirror/view";
export {
@ -65,6 +67,7 @@ export {
EditorState,
Range,
SelectionRange,
StateField,
Text,
Transaction,
} from "@codemirror/state";
@ -72,6 +75,8 @@ export type { ChangeSpec, Extension, StateCommand } from "@codemirror/state";
export {
defaultHighlightStyle,
defineLanguageFacet,
foldedRanges,
foldInside,
foldNodeProp,
HighlightStyle,
indentNodeProp,

View File

@ -8,7 +8,6 @@ type: page
tags:
- hello
- world
---
# This is a doc
@ -26,9 +25,9 @@ Deno.test("Test parser", () => {
lang,
sample1,
);
console.log("tree", JSON.stringify(tree, null, 2));
// Check if rendering back to text works
assertEquals(renderToText(tree), sample1);
// console.log("tree", JSON.stringify(tree, null, 2));
let node = findNodeOfType(tree, "FrontMatter");
assertNotEquals(node, undefined);
tree = parse(lang, sampleInvalid1);

View File

@ -23,7 +23,10 @@ import {
export const pageLinkRegex = /^\[\[([^\]]+)\]\]/;
const WikiLink: MarkdownConfig = {
defineNodes: ["WikiLink", "WikiLinkPage"],
defineNodes: ["WikiLink", "WikiLinkPage", {
name: "WikiLinkMark",
style: t.processingInstruction,
}],
parseInline: [
{
name: "WikiLink",
@ -35,9 +38,48 @@ const WikiLink: MarkdownConfig = {
) {
return -1;
}
const endPos = pos + match[0].length;
return cx.addElement(
cx.elt("WikiLink", pos, pos + match[0].length, [
cx.elt("WikiLinkPage", pos + 2, pos + match[0].length - 2),
cx.elt("WikiLink", pos, endPos, [
cx.elt("WikiLinkMark", pos, pos + 2),
cx.elt("WikiLinkPage", pos + 2, endPos - 2),
cx.elt("WikiLinkMark", endPos - 2, endPos),
]),
);
},
after: "Emphasis",
},
],
};
const commandLinkRegex = /^\{\[([^\]]+)\]\}/;
const CommandLink: MarkdownConfig = {
defineNodes: [
{ name: "CommandLink", style: { "CommandLink/...": ct.CommandLinkTag } },
{ name: "CommandLinkName", style: ct.CommandLinkNameTag },
{
name: "CommandLinkMark",
style: t.processingInstruction,
},
],
parseInline: [
{
name: "CommandLink",
parse(cx, next, pos) {
let match: RegExpMatchArray | null;
if (
next != 123 /* '{' */ ||
!(match = commandLinkRegex.exec(cx.slice(pos, cx.end)))
) {
return -1;
}
const endPos = pos + match[0].length;
return cx.addElement(
cx.elt("CommandLink", pos, endPos, [
cx.elt("CommandLinkMark", pos, pos + 2),
cx.elt("CommandLinkName", pos + 2, endPos - 2),
cx.elt("CommandLinkMark", endPos - 2, endPos),
]),
);
},
@ -102,7 +144,7 @@ export const Comment: MarkdownConfig = {
// FrontMatter parser
const lang = StreamLanguage.define(yamlLanguage);
const yamlLang = StreamLanguage.define(yamlLanguage);
export const FrontMatter: MarkdownConfig = {
defineNodes: [
@ -142,7 +184,7 @@ export const FrontMatter: MarkdownConfig = {
}
lastPos = cx.parsedPos;
} while (line.text !== "---");
const yamlTree = lang.parser.parse(text);
const yamlTree = yamlLang.parser.parse(text);
elts.push(
cx.elt("FrontMatterCode", startPos, endPos, [
@ -167,6 +209,7 @@ export default function buildMarkdown(mdExtensions: MDExt[]): Language {
return markdown({
extensions: [
WikiLink,
CommandLink,
FrontMatter,
TaskList,
Comment,
@ -179,6 +222,8 @@ export default function buildMarkdown(mdExtensions: MDExt[]): Language {
styleTags({
WikiLink: ct.WikiLinkTag,
WikiLinkPage: ct.WikiLinkPageTag,
// CommandLink: ct.CommandLinkTag,
// CommandLinkName: ct.CommandLinkNameTag,
Task: ct.TaskTag,
TaskMarker: ct.TaskMarkerTag,
Comment: ct.CommentTag,

View File

@ -49,17 +49,17 @@ name: something
Deno.test("Run a Node sandbox", () => {
const lang = wikiMarkdownLang([]);
let mdTree = parse(lang, mdTest1);
const mdTree = parse(lang, mdTest1);
addParentPointers(mdTree);
// console.log(JSON.stringify(mdTree, null, 2));
let wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
assertEquals(wikiLink.type, "WikiLink");
const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
assertEquals(wikiLink.type, "WikiLinkPage");
assertNotEquals(
findParentMatching(wikiLink, (n) => n.type === "BulletList"),
null,
);
let allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
assertEquals(allTodos.length, 2);
// Render back into markdown should be equivalent

View File

@ -118,7 +118,7 @@ export function traverseTree(
// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
if (pos < tree.from! || pos > tree.to!) {
if (pos < tree.from! || pos >= tree.to!) {
return null;
}
if (!tree.children) {

View File

@ -112,6 +112,12 @@ export function prompt(
return syscall("editor.prompt", message, defaultValue);
}
export function confirm(
message: string,
): Promise<boolean> {
return syscall("editor.confirm", message);
}
export function enableReadOnlyMode(enabled: boolean) {
return syscall("editor.enableReadOnlyMode", enabled);
}

View File

@ -12,11 +12,6 @@ syntax:
- "h"
regex: "https?:\\/\\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}([-a-zA-Z0-9()@:%_\\+.~#?&=\\/]*)"
className: sb-naked-url
CommandLink:
firstCharacters:
- "{"
regex: "\\{\\[[^\\]]+\\]\\}"
className: sb-command-link
NamedAnchor:
firstCharacters:
- "$"
@ -171,6 +166,8 @@ functions:
# Template commands
insertTemplateText:
path: "./template.ts:insertTemplateText"
applyLineReplace:
path: ./template.ts:applyLineReplace
insertFrontMatter:
redirect: insertTemplateText
slashCommand:
@ -180,18 +177,57 @@ functions:
---
|^|
---
insertTask:
redirect: insertTemplateText
makeH1:
redirect: applyLineReplace
slashCommand:
name: task
description: Insert a task
value: "* [ ] |^|"
name: h1
description: Turn line into h1 header
match: "^#*\\s*"
replace: "# "
makeH2:
redirect: applyLineReplace
slashCommand:
name: h2
description: Turn line into h2 header
match: "^#*\\s*"
replace: "## "
makeH3:
redirect: applyLineReplace
slashCommand:
name: h3
description: Turn line into h3 header
match: "^#*\\s*"
replace: "### "
makeH4:
redirect: applyLineReplace
slashCommand:
name: h4
description: Turn line into h4 header
match: "^#*\\s*"
replace: "#### "
newPage:
path: ./page.ts:newPageCommand
command:
name: "Page: New"
key: "Alt-Shift-n"
insertHRTemplate:
redirect: insertTemplateText
slashCommand:
name: hr
description: Insert a horizontal rule
value: "---"
insertTable:
redirect: insertTemplateText
slashCommand:
name: table
description: Insert a table
boost: -1 # Low boost because it's likely not very commonly used
value: |
| Header A | Header B |
|----------|----------|
| Cell A|^| | Cell B |
quickNoteCommand:
path: ./template.ts:quickNoteCommand
command:

View File

@ -1,6 +1,11 @@
import type { ClickEvent } from "$sb/app_event.ts";
import { editor, markdown, system } from "$sb/silverbullet-syscall/mod.ts";
import { nodeAtPos, ParseTree } from "$sb/lib/tree.ts";
import {
addParentPointers,
findParentMatching,
nodeAtPos,
ParseTree,
} from "$sb/lib/tree.ts";
// Checks if the URL contains a protocol, if so keeps it, otherwise assumes an attachment
function patchUrl(url: string): string {
@ -17,10 +22,19 @@ async function actionClickOrActionEnter(
if (!mdTree) {
return;
}
// console.log("Attempting to navigate based on syntax node", mdTree);
const navigationNodeFinder = (t: ParseTree) =>
["WikiLink", "Link", "URL", "NakedURL", "Link", "CommandLink"].includes(
t.type!,
);
if (!navigationNodeFinder(mdTree)) {
mdTree = findParentMatching(mdTree, navigationNodeFinder);
if (!mdTree) {
return;
}
}
switch (mdTree.type) {
case "WikiLinkPage": {
let pageLink = mdTree.children![0].text!;
case "WikiLink": {
let pageLink = mdTree.children![1]!.children![0].text!;
let pos;
if (pageLink.includes("@")) {
[pageLink, pos] = pageLink.split("@");
@ -47,11 +61,8 @@ async function actionClickOrActionEnter(
break;
}
case "CommandLink": {
const command = mdTree
.children![0].text!.substring(2, mdTree.children![0].text!.length - 2)
.trim();
console.log("Got command link", command);
await system.invokeCommand(command);
const commandName = mdTree.children![1]!.children![0].text!;
await system.invokeCommand(commandName);
break;
}
}
@ -60,6 +71,7 @@ async function actionClickOrActionEnter(
export async function linkNavigate() {
const mdTree = await markdown.parseMarkdown(await editor.getText());
const newNode = nodeAtPos(mdTree, await editor.getCursor());
addParentPointers(mdTree);
await actionClickOrActionEnter(newNode);
}
@ -69,6 +81,7 @@ export async function clickNavigate(event: ClickEvent) {
return;
}
const mdTree = await markdown.parseMarkdown(await editor.getText());
addParentPointers(mdTree);
const newNode = nodeAtPos(mdTree, event.pos);
await actionClickOrActionEnter(newNode, event.ctrlKey || event.metaKey);
}

View File

@ -92,17 +92,24 @@ export async function linkQueryProvider({
export async function deletePage() {
const pageName = await editor.getCurrentPage();
if (
!await editor.confirm(`Are you sure you would like to delete ${pageName}?`)
) {
return;
}
console.log("Navigating to index page");
await editor.navigate("");
console.log("Deleting page from space");
await space.deletePage(pageName);
}
export async function renamePage() {
export async function renamePage(targetName?: string) {
console.log("Got a target name", targetName);
const oldName = await editor.getCurrentPage();
const cursor = await editor.getCursor();
console.log("Old name is", oldName);
const newName = await editor.prompt(`Rename ${oldName} to:`, oldName);
const newName = targetName ||
await editor.prompt(`Rename ${oldName} to:`, oldName);
if (!newName) {
return;
}
@ -117,17 +124,25 @@ export async function renamePage() {
const text = await editor.getText();
console.log("Writing new page to space");
await space.writePage(newName, text);
const newPageMeta = await space.writePage(newName, text);
console.log("Navigating to new page");
await editor.navigate(newName, cursor, true);
// Handling the edge case of a changing page name just in casing on a case insensitive FS
const oldPageMeta = await space.getPageMeta(oldName);
if (oldPageMeta.lastModified !== newPageMeta.lastModified) {
// If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
console.log("Deleting page from space");
await space.deletePage(oldName);
}
const pageToUpdateSet = new Set<string>();
for (const pageToUpdate of pagesToUpdate) {
pageToUpdateSet.add(pageToUpdate.page);
}
let updatedReferences = 0;
for (const pageToUpdate of pageToUpdateSet) {
if (pageToUpdate === oldName) {
continue;
@ -146,12 +161,14 @@ export async function renamePage() {
const pageName = n.children![0].text!;
if (pageName === oldName) {
n.children![0].text = newName;
updatedReferences++;
return n;
}
// page name with @pos position
if (pageName.startsWith(`${oldName}@`)) {
const [, pos] = pageName.split("@");
n.children![0].text = `${newName}@${pos}`;
updatedReferences++;
return n;
}
}
@ -164,6 +181,20 @@ export async function renamePage() {
await space.writePage(pageToUpdate, newText);
}
}
await editor.flashNotification(
`Renamed page, and updated ${updatedReferences} references`,
);
}
export async function newPageCommand() {
const allPages = await space.listPages();
let pageName = `Untitled`;
let i = 1;
while (allPages.find((p) => p.name === pageName)) {
pageName = `Untitled ${i}`;
i++;
}
await editor.navigate(pageName);
}
type BackLink = {

View File

@ -3,6 +3,7 @@ import { extractMeta } from "../directive/data.ts";
import { renderToText } from "$sb/lib/tree.ts";
import { niceDate } from "$sb/lib/dates.ts";
import { readSettings } from "$sb/lib/settings_page.ts";
import { regexp } from "https://deno.land/std@0.163.0/encoding/_yaml/type/regexp.ts";
export async function instantiateTemplateCommand() {
const allPages = await space.listPages();
@ -210,3 +211,31 @@ export async function insertTemplateText(cmdDef: any) {
await editor.moveCursor(cursorPos + carretPos);
}
}
export async function applyLineReplace(cmdDef: any) {
const cursorPos = await editor.getCursor();
const text = await editor.getText();
const matchRegex = new RegExp(cmdDef.match);
let startOfLine = cursorPos;
while (startOfLine > 0 && text[startOfLine - 1] !== "\n") {
startOfLine--;
}
let currentLine = text.slice(startOfLine, cursorPos);
const emptyLine = !currentLine;
currentLine = currentLine.replace(matchRegex, cmdDef.replace);
await editor.dispatch({
changes: {
from: startOfLine,
to: cursorPos,
insert: currentLine,
},
selection: emptyLine
? {
anchor: startOfLine + currentLine.length,
}
: undefined,
});
}

View File

@ -294,10 +294,8 @@ function render(
body: "",
};
case "CommandLink": {
const commandText = t.children![0].text!.substring(
2,
t.children![0].text!.length - 2,
);
// Child 0 is CommandLinkMark, child 1 is CommandLinkPage
const commandText = t.children![1].children![0].text!;
return {
name: "button",

View File

@ -91,7 +91,6 @@ export function taskToggle(event: ClickEvent) {
export function previewTaskToggle(eventString: string) {
const [eventName, pos] = JSON.parse(eventString);
if (eventName === "task") {
console.log("Gotta toggle a task at", pos);
return taskToggleAtPos(+pos);
}
}
@ -107,9 +106,6 @@ async function toggleTaskMarker(node: ParseTree, moveToPos: number) {
to: node.to,
insert: changeTo,
},
selection: {
anchor: moveToPos,
},
});
const parentWikiLinks = collectNodesMatching(
@ -147,7 +143,6 @@ export async function taskToggleAtPos(pos: number) {
addParentPointers(mdTree);
const node = nodeAtPos(mdTree, pos);
// console.log("Got this node", node?.type);
if (node && node.type === "TaskMarker") {
await toggleTaskMarker(node, pos);
}

View File

@ -21,6 +21,14 @@ syntax:
styles:
backgroundColor: "rgba(22,22,22,0.07)"
functions:
turnIntoTask:
redirect: core.applyLineReplace
slashCommand:
name: task
description: Turn into task
match: "^(\\s*)[\\-\\*]?\\s*(\\[[ xX]\\])?\\s*"
replace: "$1* [ ] "
indexTasks:
path: "./task.ts:indexTasks"
events:

74
web/cm_plugins/block.ts Normal file
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 },
);

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
web/cm_plugins/clean.ts Normal file
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[];
}

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" },

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 },
);

View File

@ -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
web/cm_plugins/hide_mark.ts Normal file
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,
});

View File

@ -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({
widgets.push(
Decoration.widget({
widget: new InlineImageWidget(url, title),
});
widgets.push(deco.range(node.to));
}).range(node.to),
);
},
});
}

View File

@ -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
web/cm_plugins/link.ts Normal file
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
web/cm_plugins/list.ts Normal file
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;
}
}

View File

@ -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
web/cm_plugins/table.ts Normal file
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
web/cm_plugins/task.ts Normal file
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
web/cm_plugins/util.ts Normal file
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;
}

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,
}),
];

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);
});
}
e.stopPropagation();
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
</div>

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">
{actionButtons.map((actionButton) => (
<button
onClick={(e) => {
onHomeClick();
actionButton.callback();
e.stopPropagation();
}}
title="Navigate to the 'index' page"
title={actionButton.description}
>
<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} />
<FontAwesomeIcon icon={actionButton.icon} />
</button>
))}
</div>
</div>
</div>

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" });
onRename={(newName) => {
console.log("Now renaming page to...", newName);
editor.system.loadedPlugs.get("core")!.invoke(
"renamePage",
[newName],
).then(() => {
editor.focus();
}).catch(console.error);
}}
onThemeClick={() => {
if (localStorage.theme === "dark") localStorage.theme = "light";
else localStorage.theme = "dark";
document.documentElement.dataset.theme = localStorage.theme;
}}
onHomeClick={() => {
actionButtons={[
{
icon: faHome,
description: `Go home (Alt-h)`,
callback: () => {
editor.navigate("");
}}
onActionClick={() => {
},
},
{
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"

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({

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" },

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;
}

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 {

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;
}
}

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",

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,

View File

@ -3,6 +3,16 @@ release.
---
## 0.2.0
* The editor is now in "live preview" mode where a lot of markdown is hidden unless the cursor is present. This will take some getting used to, but results in a much more distraction free look.
* Clicking on the page name in the top bar now allows you to quickly rename pages, hit enter to apply the change.
* The previous behavior of opening the page switcher, has now moved to its own action button (the folder one)
* Changes to some slash commands:
* `/task` now is smarter and attempts to turn your current line into a task
* `/h1` through `/h4` will turn the current line into a header
---
## 0.1.5
* Rich text paste: paste content from web pages, google docs, including tables and SB will make a best effort to convert it to Markdown. Implemented using [turndown](https://github.com/mixmark-io/turndown). Probably can use some tweaking, but it's something.