Fixes #164: Rewrote all CM view plugins to statefields

pull/184/head
Zef Hemel 2022-12-09 16:09:53 +01:00
parent 453c613ef4
commit c8c4271aeb
16 changed files with 433 additions and 739 deletions

View File

@ -1,4 +1,4 @@
import { nodeAtPos, ParseTree, renderToText } from "$sb/lib/tree.ts";
import { nodeAtPos, ParseTree } from "$sb/lib/tree.ts";
import { replaceAsync } from "$sb/lib/util.ts";
import { markdown } from "$sb/silverbullet-syscall/mod.ts";

View File

@ -1,23 +1,17 @@
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
function hideNodes(view: EditorView) {
function hideNodes(state: EditorState) {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(view.state, [node.from, node.to])
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
widgets.push(
@ -29,7 +23,7 @@ function hideNodes(view: EditorView) {
if (
node.name === "Image" &&
!isCursorInRange(view.state, [node.from, node.to])
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
@ -38,7 +32,7 @@ function hideNodes(view: EditorView) {
node.name === "FrontMatterMarker"
) {
const parent = node.node.parent!;
if (!isCursorInRange(view.state, [parent.from, parent.to])) {
if (!isCursorInRange(state, [parent.from, parent.to])) {
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
@ -54,7 +48,7 @@ function hideNodes(view: EditorView) {
// Hide ONLY if CodeMark is not insine backticks (InlineCode) and the cursor is placed outside
if (
parent.node.name !== "InlineCode" &&
!isCursorInRange(view.state, [parent.from, parent.to])
!isCursorInRange(state, [parent.from, parent.to])
) {
widgets.push(
Decoration.line({
@ -68,19 +62,6 @@ function hideNodes(view: EditorView) {
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 },
);
export function cleanBlockPlugin() {
return decoratorStateField(hideNodes);
}

View File

@ -1,45 +1,26 @@
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import {
decoratorStateField,
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);
}
function decorateBlockQuote(state: EditorState) {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (isCursorInRange(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 function blockquotePlugin() {
return decoratorStateField(decorateBlockQuote);
}
export const blockquotePlugin = ViewPlugin.fromClass(
BlockquotePlugin,
{
decorations: (v) => v.decorations,
},
);

View File

@ -3,7 +3,7 @@ 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 { hideHeaderMarkPlugin, hideMarksPlugin } from "./hide_mark.ts";
import { cleanBlockPlugin } from "./block.ts";
import { linkPlugin } from "./link.ts";
import { listBulletPlugin } from "./list.ts";
@ -15,11 +15,11 @@ import { cleanCommandLinkPlugin } from "./command_link.ts";
export function cleanModePlugins(editor: Editor) {
return [
linkPlugin(editor),
directivePlugin,
blockquotePlugin,
hideMarks(),
hideHeaderMarkPlugin,
cleanBlockPlugin,
directivePlugin(),
blockquotePlugin(),
hideMarksPlugin(),
hideHeaderMarkPlugin(),
cleanBlockPlugin(),
taskListPlugin({
// TODO: Move this logic elsewhere?
onCheckboxClick: (pos) => {
@ -34,8 +34,8 @@ export function cleanModePlugins(editor: Editor) {
editor.dispatchAppEvent("page:click", clickEvent);
},
}),
listBulletPlugin,
tablePlugin,
listBulletPlugin(),
tablePlugin(editor),
cleanWikiLinkPlugin(editor),
cleanCommandLinkPlugin(editor),
] as Extension[];

View File

@ -1,99 +1,75 @@
import { commandLinkRegex, pageLinkRegex } from "../../common/parser.ts";
import { ClickEvent } from "../../plug-api/app_event.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { commandLinkRegex } from "../../common/parser.ts";
import { ClickEvent } from "$sb/app_event.ts";
import { Decoration, syntaxTree } from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
ButtonWidget,
decoratorStateField,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
/**
* Plugin to hide path prefix when the cursor is not inside.
*/
export function cleanCommandLinkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
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);
return decoratorStateField((state) => {
const widgets: any[] = [];
// let parentRange: [number, number];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== "CommandLink") {
return;
}
if (isCursorInRange(state, [from, to])) {
return;
}
}
compute(view: EditorView): DecorationSet {
const widgets: any[] = [];
// let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (type.name !== "CommandLink") {
return;
}
if (isCursorInRange(view.state, [from, to])) {
return;
}
const text = view.state.sliceDoc(from, to);
const match = commandLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, command, _pipePart, alias] = match;
const text = state.sliceDoc(from, to);
const match = commandLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, command, _pipePart, alias] = match;
// Hide the whole thing
widgets.push(
invisibleDecoration.range(
from,
to,
),
);
// Hide the whole thing
widgets.push(
invisibleDecoration.range(
from,
to,
),
);
const linkText = alias || command;
// And replace it with a widget
widgets.push(
Decoration.widget({
widget: new ButtonWidget(
linkText,
`Run command: ${command}`,
"sb-command-button",
(e) => {
if (e.altKey) {
// Move cursor into the link
return view.dispatch({
selection: { anchor: from + 2 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
}
},
{
decorations: (v) => v.decorations,
},
);
const linkText = alias || command;
// And replace it with a widget
widgets.push(
Decoration.widget({
widget: new ButtonWidget(
linkText,
`Run command: ${command}`,
"sb-command-button",
(e) => {
if (e.altKey) {
// Move cursor into the link
return editor.editorView!.dispatch({
selection: { anchor: from + 2 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
});
}

View File

@ -1,70 +1,44 @@
import {
Decoration,
DecorationSet,
EditorView,
syntaxTree,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { isCursorInRange } from "./util.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import { decoratorStateField, isCursorInRange } from "./util.ts";
function getDirectives(view: EditorView) {
function getDirectives(state: EditorState) {
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),
);
}
},
});
}
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== "CommentBlock") {
return;
}
const text = 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(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 },
);
export function directivePlugin() {
return decoratorStateField(getDirectives);
}

View File

@ -2,18 +2,12 @@
// Original author: Pranav Karawale
// License: Apache License 2.0.
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import {
checkRangeOverlap,
decoratorStateField,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
/**
@ -37,22 +31,16 @@ const markTypes = [
];
/**
* Plugin to hide marks when the they are not in the editor selection.
* Ixora hide marks plugin.
*
* This plugin allows to:
* - Hide marks when 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 {
export function hideMarksPlugin() {
return decoratorStateField((state: EditorState) => {
const widgets: any[] = [];
let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
syntaxTree(state).iterate({
enter: ({ type, from, to, node }) => {
if (typesWithMarks.includes(type.name)) {
// There can be a possibility that the current node is a
@ -64,7 +52,7 @@ class HideMarkPlugin {
) {
return;
} else parentRange = [from, to];
if (isCursorInRange(view.state, [from, to])) return;
if (isCursorInRange(state, [from, to])) return;
const innerTree = node.toTree();
innerTree.iterate({
enter({ type, from: markFrom, to: markTo }) {
@ -83,78 +71,36 @@ class HideMarkPlugin {
},
});
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) {
export function hideHeaderMarkPlugin() {
return decoratorStateField((state) => {
const widgets: any[] = [];
const ranges = view.state.selection.ranges;
iterateTreeInVisibleRanges(view, {
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (!type.name.startsWith("ATXHeading")) {
return;
}
// 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") {
const line = state.sliceDoc(from, to);
if (isCursorInRange(state, [from, to])) {
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));
}
widgets.push(
invisibleDecoration.range(
from,
from + line.indexOf(" ") + 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,14 +1,11 @@
import {
Decoration,
DecorationSet,
EditorView,
EditorState,
Range,
syntaxTree,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "../deps.ts";
import { invisibleDecoration, isCursorInRange } from "./util.ts";
import { decoratorStateField } from "./util.ts";
class InlineImageWidget extends WidgetType {
constructor(readonly url: string, readonly title: string) {
@ -35,21 +32,19 @@ class InlineImageWidget extends WidgetType {
}
}
const inlineImages = (view: EditorView) => {
const widgets: Range<Decoration>[] = [];
const imageRegex = /!\[(?<title>[^\]]*)\]\((?<url>.+)\)/;
export function inlineImagesPlugin() {
return decoratorStateField((state: EditorState) => {
const widgets: Range<Decoration>[] = [];
const imageRegex = /!\[(?<title>[^\]]*)\]\((?<url>.+)\)/;
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
syntaxTree(state).iterate({
enter: (node) => {
if (node.name !== "Image") {
return;
}
const imageRexexResult = imageRegex.exec(
view.state.sliceDoc(node.from, node.to),
state.sliceDoc(node.from, node.to),
);
if (imageRexexResult === null || !imageRexexResult.groups) {
return;
@ -64,27 +59,7 @@ const inlineImages = (view: EditorView) => {
);
},
});
}
return Decoration.set(widgets, true);
};
export const inlineImagesPlugin = () =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = inlineImages(view);
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.decorations = inlineImages(update.view);
}
}
},
{
decorations: (v) => v.decorations,
},
);
return Decoration.set(widgets, true);
});
}

View File

@ -1,12 +1,5 @@
import {
Decoration,
DecorationSet,
EditorView,
Range,
syntaxTree,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { Decoration, EditorState, Range, syntaxTree } from "../deps.ts";
import { decoratorStateField } from "./util.ts";
interface WrapElement {
selector: string;
@ -14,16 +7,12 @@ interface WrapElement {
nesting?: boolean;
}
function wrapLines(view: EditorView, wrapElements: WrapElement[]) {
let widgets: Range<Decoration>[] = [];
const elementStack: string[] = [];
const doc = view.state.doc;
// Disabling the visible ranges for now, because it may be a bit buggy.
// RISK: this may actually become slow for large documents.
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
export function lineWrapper(wrapElements: WrapElement[]) {
return decoratorStateField((state: EditorState) => {
const widgets: Range<Decoration>[] = [];
const elementStack: string[] = [];
const doc = state.doc;
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
for (const wrapElement of wrapElements) {
if (type.name == wrapElement.selector) {
@ -55,30 +44,7 @@ function wrapLines(view: EditorView, wrapElements: WrapElement[]) {
}
},
});
}
// Widgets have to be sorted by `from` in ascending order
widgets = widgets.sort((a, b) => {
return a.from < b.from ? -1 : 1;
return Decoration.set(widgets, true);
});
return Decoration.set(widgets);
}
export const lineWrapper = (wrapElements: WrapElement[]) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = wrapLines(view, wrapElements);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = wrapLines(update.view, wrapElements);
}
}
},
{
decorations: (v) => v.decorations,
},
);

View File

@ -1,104 +1,79 @@
import { ClickEvent } from "../../plug-api/app_event.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { Decoration, syntaxTree } from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} from "./util.ts";
import { LinkWidget } from "./util.ts";
export function linkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(readonly view: EditorView) {
this.decorations = this.calculateDecorations();
}
calculateDecorations() {
const widgets: any[] = [];
const view = this.view;
return decoratorStateField((state) => {
const widgets: any[] = [];
iterateTreeInVisibleRanges(this.view, {
enter: ({ type, from, to }) => {
if (type.name !== "Link") {
return;
}
// Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
if (isCursorInRange(view.state, [from, to])) {
return;
}
const text = view.state.sliceDoc(from, to);
// Links are of the form [hell](https://example.com)
const [anchorPart, linkPart] = text.split("]("); // Not pretty
if (!linkPart) {
// Invalid link
return;
}
const cleanAnchor = anchorPart.substring(1); // cut off the initial [
const cleanLink = linkPart.substring(0, linkPart.length - 1); // cut off the final )
// Hide the whole thing
widgets.push(
invisibleDecoration.range(
from,
to,
),
);
widgets.push(
Decoration.widget({
widget: new LinkWidget(
{
text: cleanAnchor,
title: `Click to visit ${cleanLink}`,
cssClass: "sb-link",
href: cleanLink,
callback: (e) => {
if (e.altKey) {
// Move cursor into the link, approximate location
return view.dispatch({
selection: { anchor: from + 1 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
update.selectionSet
) {
this.decorations = this.calculateDecorations();
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== "Link") {
return;
}
}
},
{ decorations: (v) => v.decorations },
);
// Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
if (isCursorInRange(state, [from, to])) {
return;
}
const text = state.sliceDoc(from, to);
// Links are of the form [hell](https://example.com)
const [anchorPart, linkPart] = text.split("]("); // Not pretty
if (!linkPart) {
// Invalid link
return;
}
const cleanAnchor = anchorPart.substring(1); // cut off the initial [
const cleanLink = linkPart.substring(0, linkPart.length - 1); // cut off the final )
// Hide the whole thing
widgets.push(
invisibleDecoration.range(
from,
to,
),
);
widgets.push(
Decoration.widget({
widget: new LinkWidget(
{
text: cleanAnchor,
title: `Click to visit ${cleanLink}`,
cssClass: "sb-link",
href: cleanLink,
callback: (e) => {
if (e.altKey) {
// Move cursor into the link, approximate location
return editor.editorView!.dispatch({
selection: { anchor: from + 1 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
});
}

View File

@ -2,38 +2,19 @@
// 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";
import { Decoration, syntaxTree, WidgetType } from "../deps.ts";
import { decoratorStateField, isCursorInRange } 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) {
export function listBulletPlugin() {
return decoratorStateField((state) => {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (isCursorInRange(view.state, [from, to])) return;
if (isCursorInRange(state, [from, to])) return;
if (type.name === "ListMark") {
const listMark = view.state.sliceDoc(from, to);
const listMark = state.sliceDoc(from, to);
if (bulletListMarkerRE.test(listMark)) {
const dec = Decoration.replace({
widget: new ListBulletWidget(listMark),
@ -44,11 +25,8 @@ class ListBulletPlugin {
},
});
return Decoration.set(widgets, true);
}
});
}
export const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, {
decorations: (v) => v.decorations,
});
/**
* Widget to render list bullet mark.

View File

@ -1,21 +1,20 @@
import {
Decoration,
DecorationSet,
EditorState,
EditorView,
ViewPlugin,
ViewUpdate,
syntaxTree,
WidgetType,
} from "../deps.ts";
import {
editorLines,
decoratorStateField,
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";
import type { Editor } from "../editor.tsx";
class TableViewWidget extends WidgetType {
constructor(
@ -49,25 +48,27 @@ class TableViewWidget extends WidgetType {
}
}
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) {
export function tablePlugin(editor: Editor) {
return decoratorStateField((state: EditorState) => {
const widgets: any[] = [];
iterateTreeInVisibleRanges(view, {
syntaxTree(state).iterate({
enter: (node) => {
const { from, to, name } = node;
if (name !== "Table") return;
if (isCursorInRange(view.state, [from, to])) return;
if (isCursorInRange(state, [from, to])) return;
const lines = editorLines(view, from, to);
const tableText = state.sliceDoc(from, to);
const lineStrings = tableText.split("\n");
const lines: { from: number; to: number }[] = [];
let fromIt = from;
for (const line of lineStrings) {
lines.push({
from: fromIt,
to: fromIt + line.length,
});
fromIt += line.length + 1;
}
const firstLine = lines[0], lastLine = lines[lines.length - 1];
@ -84,12 +85,12 @@ class TablePlugin {
),
);
});
const text = view.state.sliceDoc(0, to);
const text = state.sliceDoc(0, to);
widgets.push(
Decoration.widget({
widget: new TableViewWidget(
from,
view,
editor.editorView!,
lezerToParseTree(text, node.node),
),
}).range(from),
@ -97,11 +98,5 @@ class TablePlugin {
},
});
return Decoration.set(widgets, true);
}
});
}
export const tablePlugin = ViewPlugin.fromClass(
TablePlugin,
{
decorations: (v) => v.decorations,
},
);

View File

@ -1,73 +1,5 @@
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));
}
};
}
};
}
import { Decoration, NodeType, syntaxTree, WidgetType } from "../deps.ts";
import { decoratorStateField, isCursorInRange } from "./util.ts";
/**
* Widget to render checkbox for a task list item.
@ -80,7 +12,7 @@ class CheckboxWidget extends WidgetType {
) {
super();
}
toDOM(_view: EditorView): HTMLElement {
toDOM(): HTMLElement {
const wrap = document.createElement("span");
wrap.classList.add("sb-checkbox");
const checkbox = document.createElement("input");
@ -100,7 +32,42 @@ class CheckboxWidget extends WidgetType {
export function taskListPlugin(
{ onCheckboxClick }: { onCheckboxClick: (pos: number) => void },
) {
return ViewPlugin.fromClass(TaskListsPluginFactory(onCheckboxClick), {
decorations: (v) => v.decorations,
return decoratorStateField((state) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter({ type, from, to, node }) {
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(state, [from + nfrom, from + nto])) return;
const checkbox = 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));
}
},
});
return Decoration.set(widgets, true);
});
}

View File

@ -3,11 +3,12 @@
// License: Apache License 2.0.
import {
Decoration,
DecorationSet,
EditorState,
EditorView,
foldedRanges,
SyntaxNodeRef,
syntaxTree,
StateField,
Transaction,
WidgetType,
} from "../deps.ts";
@ -39,6 +40,25 @@ export class LinkWidget extends WidgetType {
}
}
export function decoratorStateField(
stateToDecoratorMapper: (state: EditorState) => DecorationSet,
) {
return StateField.define<DecorationSet>({
create(state: EditorState) {
return stateToDecoratorMapper(state);
},
update(value: DecorationSet, tr: Transaction) {
// if (tr.docChanged || tr.selection) {
return stateToDecoratorMapper(tr.state);
// }
// return value;
},
provide: (f) => EditorView.decorations.from(f),
});
}
export class ButtonWidget extends WidgetType {
constructor(
readonly text: string,
@ -106,19 +126,6 @@ export function isCursorInRange(state: EditorState, range: [number, number]) {
*/
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

View File

@ -1,17 +1,11 @@
import { pageLinkRegex } from "../../common/parser.ts";
import { ClickEvent } from "../../plug-api/app_event.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { Decoration, syntaxTree } from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
LinkWidget,
} from "./util.ts";
@ -19,119 +13,99 @@ import {
* Plugin to hide path prefix when the cursor is not inside.
*/
export function cleanWikiLinkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
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);
return decoratorStateField((state) => {
const widgets: any[] = [];
// let parentRange: [number, number];
syntaxTree(state).iterate({
enter: ({ type, from, to }) => {
if (type.name !== "WikiLink") {
return;
}
}
compute(view: EditorView): DecorationSet {
const widgets: any[] = [];
// let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => {
if (type.name !== "WikiLink") {
return;
}
const text = view.state.sliceDoc(from, to);
const match = pageLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, page, pipePart, alias] = match;
const text = state.sliceDoc(from, to);
const match = pageLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, page, pipePart, alias] = match;
const allPages = editor.space.listPages();
let pageExists = false;
let cleanPage = page;
if (page.includes("@")) {
cleanPage = page.split("@")[0];
}
for (const pageMeta of allPages) {
if (pageMeta.name === cleanPage) {
pageExists = true;
break;
}
}
if (cleanPage === "") {
// Empty page name, or local @anchor use
pageExists = true;
}
const allPages = editor.space.listPages();
let pageExists = false;
let cleanPage = page;
if (page.includes("@")) {
cleanPage = page.split("@")[0];
}
for (const pageMeta of allPages) {
if (pageMeta.name === cleanPage) {
pageExists = true;
break;
}
}
if (cleanPage === "") {
// Empty page name, or local @anchor use
pageExists = true;
}
if (isCursorInRange(view.state, [from, to])) {
// Only attach a CSS class, then get out
if (!pageExists) {
widgets.push(
Decoration.mark({
class: "sb-wiki-link-page-missing",
}).range(from + 2, from + page.length + 2),
);
}
return;
}
// Hide the whole thing
if (isCursorInRange(state, [from, to])) {
// Only attach a CSS class, then get out
if (!pageExists) {
widgets.push(
invisibleDecoration.range(
from,
to,
),
Decoration.mark({
class: "sb-wiki-link-page-missing",
}).range(from + 2, from + page.length + 2),
);
}
return;
}
let linkText = alias || page;
if (!pipePart && text.indexOf("/") !== -1) {
// Let's use the last part of the path as the link text
linkText = page.split("/").pop()!;
}
// Hide the whole thing
widgets.push(
invisibleDecoration.range(
from,
to,
),
);
// And replace it with a widget
widgets.push(
Decoration.widget({
widget: new LinkWidget(
{
text: linkText,
title: pageExists
? `Navigate to ${page}`
: `Create ${page}`,
href: `/${page.replaceAll(" ", "_")}`,
cssClass: pageExists
? "sb-wiki-link-page"
: "sb-wiki-link-page-missing",
callback: (e) => {
if (e.altKey) {
// Move cursor into the link
return view.dispatch({
selection: { anchor: from + 2 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
}
},
{
decorations: (v) => v.decorations,
},
);
let linkText = alias || page;
if (!pipePart && text.indexOf("/") !== -1) {
// Let's use the last part of the path as the link text
linkText = page.split("/").pop()!;
}
// And replace it with a widget
widgets.push(
Decoration.widget({
widget: new LinkWidget(
{
text: linkText,
title: pageExists ? `Navigate to ${page}` : `Create ${page}`,
href: `/${page.replaceAll(" ", "_")}`,
cssClass: pageExists
? "sb-wiki-link-page"
: "sb-wiki-link-page-missing",
callback: (e) => {
if (e.altKey) {
// Move cursor into the link
return editor.editorView!.dispatch({
selection: { anchor: from + 2 },
});
}
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
},
),
}).range(from),
);
},
});
return Decoration.set(widgets, true);
});
}

View File

@ -734,7 +734,6 @@ export class Editor {
const editorView = this.editorView!;
if (pageState) {
// Restore state
// console.log("Restoring selection state", pageState);
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
editorView.dispatch({
selection: pageState.selection,