parent
c9713bf52b
commit
24c17a793f
|
@ -8,4 +8,5 @@ website_build
|
|||
data.db
|
||||
publish-data.db
|
||||
/index.json
|
||||
.idea
|
||||
.idea
|
||||
deno.lock
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
console.log("Deleting page from space");
|
||||
await space.deletePage(oldName);
|
||||
|
||||
// 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 = {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 },
|
||||
);
|
|
@ -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,
|
||||
},
|
||||
);
|
|
@ -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,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" },
|
|
@ -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";
|
|
@ -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;
|
|
@ -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 },
|
||||
);
|
|
@ -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",
|
|
@ -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,
|
||||
},
|
||||
);
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
}),
|
||||
];
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
143
web/editor.tsx
143
web/editor.tsx
|
@ -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"
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue