@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Extension, WebsocketProvider, Y, yCollab } from "../deps.ts";
|
||||
|
||||
const userColors = [
|
||||
{ color: "#30bced", light: "#30bced33" },
|
||||
{ color: "#6eeb83", light: "#6eeb8333" },
|
||||
{ color: "#ffbc42", light: "#ffbc4233" },
|
||||
{ color: "#ecd444", light: "#ecd44433" },
|
||||
{ color: "#ee6352", light: "#ee635233" },
|
||||
{ color: "#9ac2c9", light: "#9ac2c933" },
|
||||
{ color: "#8acb88", light: "#8acb8833" },
|
||||
{ color: "#1be7ff", light: "#1be7ff33" },
|
||||
];
|
||||
|
||||
export class CollabState {
|
||||
ydoc: Y.Doc;
|
||||
collabProvider: WebsocketProvider;
|
||||
ytext: Y.Text;
|
||||
yundoManager: Y.UndoManager;
|
||||
|
||||
constructor(serverUrl: string, token: string, username: string) {
|
||||
this.ydoc = new Y.Doc();
|
||||
this.collabProvider = new WebsocketProvider(
|
||||
serverUrl,
|
||||
token,
|
||||
this.ydoc,
|
||||
);
|
||||
|
||||
this.collabProvider.on("status", (e: any) => {
|
||||
console.log("Collab status change", e);
|
||||
});
|
||||
this.collabProvider.on("sync", (e: any) => {
|
||||
console.log("Sync status", e);
|
||||
});
|
||||
|
||||
this.ytext = this.ydoc.getText("codemirror");
|
||||
this.yundoManager = new Y.UndoManager(this.ytext);
|
||||
|
||||
const randomColor =
|
||||
userColors[Math.floor(Math.random() * userColors.length)];
|
||||
|
||||
this.collabProvider.awareness.setLocalStateField("user", {
|
||||
name: username,
|
||||
color: randomColor.color,
|
||||
colorLight: randomColor.light,
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.collabProvider.destroy();
|
||||
}
|
||||
|
||||
collabExtension(): Extension {
|
||||
return yCollab(this.ytext, this.collabProvider.awareness, {
|
||||
undoManager: this.yundoManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
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";
|
||||
|
||||
// With tables and task notation as well
|
||||
import {
|
||||
tables,
|
||||
taskListItems,
|
||||
} from "https://cdn.skypack.dev/@joplin/turndown-plugin-gfm@1.0.45";
|
||||
const turndownService = new TurndownService({
|
||||
hr: "---",
|
||||
codeBlockStyle: "fenced",
|
||||
headingStyle: "atx",
|
||||
emDelimiter: "*",
|
||||
bulletListMarker: "*", // Duh!
|
||||
strongDelimiter: "**",
|
||||
linkStyle: "inlined",
|
||||
});
|
||||
turndownService.use(taskListItems);
|
||||
turndownService.use(tables);
|
||||
|
||||
function striptHtmlComments(s: string): string {
|
||||
return s.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
const urlRegexp =
|
||||
/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
|
||||
|
||||
// Known iOS Safari paste issue (unrelated to this implementation): https://voxpelli.com/2015/03/ios-safari-url-copy-paste-bug/
|
||||
export const pasteLinkExtension = ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate): void {
|
||||
update.transactions.forEach((tr) => {
|
||||
if (tr.isUserEvent("input.paste")) {
|
||||
const pastedText: string[] = [];
|
||||
let from = 0;
|
||||
let to = 0;
|
||||
tr.changes.iterChanges((fromA, _toA, _fromB, toB, inserted) => {
|
||||
pastedText.push(inserted.sliceString(0));
|
||||
from = fromA;
|
||||
to = toB;
|
||||
});
|
||||
const pastedString = pastedText.join("");
|
||||
if (pastedString.match(urlRegexp)) {
|
||||
const selection = update.startState.selection.main;
|
||||
if (!selection.empty) {
|
||||
setTimeout(() => {
|
||||
update.view.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: from,
|
||||
to: to,
|
||||
insert: `[${
|
||||
update.startState.sliceDoc(
|
||||
selection.from,
|
||||
selection.to,
|
||||
)
|
||||
}](${pastedString})`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function attachmentExtension(editor: Editor) {
|
||||
return EditorView.domEventHandlers({
|
||||
dragover: (event) => {
|
||||
event.preventDefault();
|
||||
},
|
||||
drop: (event: DragEvent) => {
|
||||
// TODO: This doesn't take into account the target cursor position,
|
||||
// it just drops the attachment wherever the cursor was last.
|
||||
if (event.dataTransfer) {
|
||||
const payload = [...event.dataTransfer.files];
|
||||
if (!payload.length) {
|
||||
return;
|
||||
}
|
||||
safeRun(async () => {
|
||||
await processFileTransfer(payload);
|
||||
});
|
||||
}
|
||||
},
|
||||
paste: (event: ClipboardEvent) => {
|
||||
const payload = [...event.clipboardData!.items];
|
||||
const richText = event.clipboardData?.getData("text/html");
|
||||
if (richText) {
|
||||
const markdown = striptHtmlComments(turndownService.turndown(richText))
|
||||
.trim();
|
||||
const view = editor.editorView!;
|
||||
const selection = view.state.selection.main;
|
||||
console.log("Rich text", markdown);
|
||||
view.dispatch({
|
||||
changes: [
|
||||
{
|
||||
from: selection.from,
|
||||
to: selection.to,
|
||||
insert: markdown,
|
||||
},
|
||||
],
|
||||
selection: {
|
||||
anchor: selection.from + markdown.length,
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!payload.length || payload.length === 0) {
|
||||
return false;
|
||||
}
|
||||
safeRun(async () => {
|
||||
await processItemTransfer(payload);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function processFileTransfer(payload: File[]) {
|
||||
const data = await payload[0].arrayBuffer();
|
||||
await saveFile(data!, payload[0].name, payload[0].type);
|
||||
}
|
||||
|
||||
async function processItemTransfer(payload: DataTransferItem[]) {
|
||||
const file = payload.find((item) => item.kind === "file");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
const fileType = file.type;
|
||||
const ext = fileType.split("/")[1];
|
||||
const fileName = new Date()
|
||||
.toISOString()
|
||||
.split(".")[0]
|
||||
.replace("T", "_")
|
||||
.replaceAll(":", "-");
|
||||
const data = await file!.getAsFile()?.arrayBuffer();
|
||||
await saveFile(data!, `${fileName}.${ext}`, fileType);
|
||||
}
|
||||
|
||||
async function saveFile(
|
||||
data: ArrayBuffer,
|
||||
suggestedName: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
if (data!.byteLength > maximumAttachmentSize) {
|
||||
editor.flashNotification(
|
||||
`Attachment is too large, maximum is ${
|
||||
maximumAttachmentSize / 1024 / 1024
|
||||
}MB`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalFileName = prompt(
|
||||
"File name for pasted attachment",
|
||||
suggestedName,
|
||||
);
|
||||
if (!finalFileName) {
|
||||
return;
|
||||
}
|
||||
await editor.space.writeAttachment(finalFileName, "arraybuffer", data!);
|
||||
let attachmentMarkdown = `[${finalFileName}](${finalFileName})`;
|
||||
if (mimeType.startsWith("image/")) {
|
||||
attachmentMarkdown = ``;
|
||||
}
|
||||
editor.editorView!.dispatch({
|
||||
changes: [
|
||||
{
|
||||
insert: attachmentMarkdown,
|
||||
from: editor.editorView!.state.selection.main.from,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
Range,
|
||||
syntaxTree,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "../deps.ts";
|
||||
import { invisibleDecoration, isCursorInRange } from "./util.ts";
|
||||
|
||||
class InlineImageWidget extends WidgetType {
|
||||
constructor(readonly url: string, readonly title: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: InlineImageWidget) {
|
||||
return other.url === this.url && other.title === this.title;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const img = document.createElement("img");
|
||||
if (this.url.startsWith("http")) {
|
||||
img.src = this.url;
|
||||
} else {
|
||||
img.src = `fs/${this.url}`;
|
||||
}
|
||||
img.alt = this.title;
|
||||
img.title = this.title;
|
||||
img.style.display = "block";
|
||||
img.className = "sb-inline-img";
|
||||
|
||||
return img;
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImages = (view: EditorView) => {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
const imageRegex = /!\[(?<title>[^\]]*)\]\((?<url>.+)\)/;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (node.name !== "Image") {
|
||||
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),
|
||||
);
|
||||
if (imageRexexResult === null || !imageRexexResult.groups) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = imageRexexResult.groups.url;
|
||||
const title = imageRexexResult.groups.title;
|
||||
widgets.push(
|
||||
Decoration.widget({
|
||||
widget: new InlineImageWidget(url, title),
|
||||
}).range(node.to),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
Range,
|
||||
syntaxTree,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from "../deps.ts";
|
||||
|
||||
interface WrapElement {
|
||||
selector: string;
|
||||
class: string;
|
||||
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,
|
||||
enter: ({ type, from, to }) => {
|
||||
for (const wrapElement of wrapElements) {
|
||||
if (type.name == wrapElement.selector) {
|
||||
if (wrapElement.nesting) {
|
||||
elementStack.push(type.name);
|
||||
}
|
||||
const bodyText = doc.sliceString(from, to);
|
||||
let idx = from;
|
||||
for (const line of bodyText.split("\n")) {
|
||||
let cls = wrapElement.class;
|
||||
if (wrapElement.nesting) {
|
||||
cls = `${cls} ${cls}-${elementStack.length}`;
|
||||
}
|
||||
widgets.push(
|
||||
Decoration.line({
|
||||
class: cls,
|
||||
}).range(doc.lineAt(idx).from),
|
||||
);
|
||||
idx += line.length + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave({ type }) {
|
||||
for (const wrapElement of wrapElements) {
|
||||
if (type.name == wrapElement.selector && wrapElement.nesting) {
|
||||
elementStack.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { KeyBinding, syntaxTree } from "../deps.ts";
|
||||
|
||||
const straightQuoteContexts = [
|
||||
"CommentBlock",
|
||||
"FencedCode",
|
||||
"InlineCode",
|
||||
"FrontMatterCode",
|
||||
];
|
||||
|
||||
// TODO: Add support for selection (put quotes around or create blockquote block?)
|
||||
function keyBindingForQuote(
|
||||
quote: string,
|
||||
left: string,
|
||||
right: string,
|
||||
): KeyBinding {
|
||||
return {
|
||||
any: (target, event): boolean => {
|
||||
// Moving this check here rather than using the regular "key" property because
|
||||
// for some reason the "ä" key is not recognized as a quote key by CodeMirror.
|
||||
if (event.key !== quote) {
|
||||
return false;
|
||||
}
|
||||
const cursorPos = target.state.selection.main.from;
|
||||
const chBefore = target.state.sliceDoc(cursorPos - 1, cursorPos);
|
||||
|
||||
// Figure out the context, if in some sort of code/comment fragment don't be smart
|
||||
let node = syntaxTree(target.state).resolveInner(cursorPos);
|
||||
while (node) {
|
||||
if (straightQuoteContexts.includes(node.type.name)) {
|
||||
return false;
|
||||
}
|
||||
if (node.parent) {
|
||||
node = node.parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ok, still here, let's use a smart quote
|
||||
let q = right;
|
||||
if (/\W/.exec(chBefore) && !/[!\?,\.\-=“]/.exec(chBefore)) {
|
||||
q = left;
|
||||
}
|
||||
target.dispatch({
|
||||
changes: {
|
||||
insert: q,
|
||||
from: cursorPos,
|
||||
},
|
||||
selection: {
|
||||
anchor: cursorPos + 1,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const smartQuoteKeymap: KeyBinding[] = [
|
||||
keyBindingForQuote('"', "“", "”"),
|
||||
keyBindingForQuote("'", "‘", "’"),
|
||||
];
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
Reference in New Issue
Block a user