Awesome frontmatter (#617)

Live Frontmatter Templates
This commit is contained in:
Zef Hemel
2024-01-04 20:08:12 +01:00
committed by GitHub
parent 9040993232
commit 91027af5fe
53 changed files with 646 additions and 342 deletions
+30 -68
View File
@@ -1,76 +1,38 @@
import { Decoration, EditorState, foldedRanges, syntaxTree } from "../deps.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import {
decoratorStateField,
HtmlWidget,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
function hideNodes(state: EditorState) {
const widgets: any[] = [];
const foldRanges = foldedRanges(state);
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(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 === "Image" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
if (
node.name === "FrontMatterMarker"
) {
const parent = node.node.parent!;
const folded = foldRanges.iter();
let shouldShowFrontmatterBanner = false;
while (folded.value) {
// Check if cursor is in the folded range
if (isCursorInRange(state, [folded.from, folded.to])) {
// console.log("Cursor is in folded area, ");
shouldShowFrontmatterBanner = true;
break;
}
folded.next();
}
if (!isCursorInRange(state, [parent.from, parent.to])) {
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(node.from),
);
shouldShowFrontmatterBanner = true;
}
if (shouldShowFrontmatterBanner && parent.from === node.from) {
// Only put this on the first line of the frontmatter
widgets.push(
Decoration.widget({
widget: new HtmlWidget(
`frontmatter`,
"sb-frontmatter-marker",
),
}).range(node.from),
);
}
}
},
});
return Decoration.set(widgets, true);
}
export function cleanBlockPlugin() {
return decoratorStateField(hideNodes);
return decoratorStateField(
(state: EditorState) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "HorizontalRule" &&
!isCursorInRange(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 === "Image" &&
!isCursorInRange(state, [node.from, node.to])
) {
widgets.push(invisibleDecoration.range(node.from, node.to));
}
},
});
return Decoration.set(widgets, true);
},
);
}
+11 -9
View File
@@ -12,33 +12,35 @@ import { taskListPlugin } from "./task.ts";
import { cleanWikiLinkPlugin } from "./wiki_link.ts";
import { cleanCommandLinkPlugin } from "./command_link.ts";
import { fencedCodePlugin } from "./fenced_code.ts";
import { frontmatterPlugin } from "./frontmatter.ts";
export function cleanModePlugins(editor: Client) {
export function cleanModePlugins(client: Client) {
return [
linkPlugin(editor),
linkPlugin(client),
blockquotePlugin(),
admonitionPlugin(editor),
admonitionPlugin(client),
hideMarksPlugin(),
hideHeaderMarkPlugin(),
cleanBlockPlugin(),
fencedCodePlugin(editor),
frontmatterPlugin(client),
fencedCodePlugin(client),
taskListPlugin({
// TODO: Move this logic elsewhere?
onCheckboxClick: (pos) => {
const clickEvent: ClickEvent = {
page: editor.currentPage!,
page: client.currentPage!,
altKey: false,
ctrlKey: false,
metaKey: false,
pos: pos,
};
// Propagate click event from checkbox
editor.dispatchAppEvent("page:click", clickEvent);
client.dispatchAppEvent("page:click", clickEvent);
},
}),
listBulletPlugin(),
tablePlugin(editor),
cleanWikiLinkPlugin(editor),
cleanCommandLinkPlugin(editor),
tablePlugin(client),
cleanWikiLinkPlugin(client),
cleanCommandLinkPlugin(client),
] as Extension[];
}
+5 -1
View File
@@ -71,11 +71,15 @@ export function fencedCodePlugin(editor: Client) {
);
});
const bodyText = lineStrings.slice(1, lineStrings.length - 1).join(
"\n",
);
const widget = renderMode === "markdown"
? new MarkdownWidget(
from + lineStrings[0].length + 1,
editor,
lineStrings.slice(1, lineStrings.length - 1).join("\n"),
`widget:${editor.currentPage}:${bodyText}`,
bodyText,
codeWidgetCallback,
"sb-markdown-widget",
)
+84
View File
@@ -0,0 +1,84 @@
import { Client } from "../client.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import { MarkdownWidget } from "./markdown_widget.ts";
import { decoratorStateField, HtmlWidget, isCursorInRange } from "./util.ts";
export function frontmatterPlugin(client: Client) {
const panelWidgetHook = client.system.panelWidgetHook;
const frontmatterCallback = panelWidgetHook.callbacks.get("frontmatter");
return decoratorStateField(
(state: EditorState) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "FrontMatter"
) {
if (!isCursorInRange(state, [node.from, node.to])) {
if (frontmatterCallback) {
// Render as a widget
const text = state.sliceDoc(node.from, node.to);
const lineStrings = text.split("\n");
const lines: { from: number; to: number }[] = [];
let fromIt = node.from;
for (const line of lineStrings) {
lines.push({
from: fromIt,
to: fromIt + line.length,
});
fromIt += line.length + 1;
}
lines.slice(0, lines.length - 1).forEach((line) => {
widgets.push(
// Reusing line-table-outside here for laziness reasons
Decoration.line({ class: "sb-line-table-outside" }).range(
line.from,
),
);
});
widgets.push(
Decoration.widget({
widget: new MarkdownWidget(
undefined,
client,
`frontmatter:${client.currentPage}`,
"",
frontmatterCallback,
"sb-markdown-frontmatter-widget",
),
block: true,
}).range(lines[lines.length - 1].from),
);
} else if (!frontmatterCallback) {
// Not rendering as a widget
widgets.push(
Decoration.widget({
widget: new HtmlWidget(
`frontmatter`,
"sb-frontmatter-marker",
),
}).range(node.from),
);
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(node.from),
);
widgets.push(
Decoration.line({
class: "sb-line-frontmatter-outside",
}).range(state.doc.lineAt(node.to).from),
);
}
}
}
},
});
return Decoration.set(widgets, true);
},
);
}
+77 -33
View File
@@ -5,17 +5,16 @@ import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { resolveAttachmentPath } from "$sb/lib/resolve.ts";
import { parse } from "../../common/markdown_parser/parse_tree.ts";
import buildMarkdown from "../../common/markdown_parser/parser.ts";
import { renderToText } from "$sb/lib/tree.ts";
const activeWidgets = new Set<MarkdownWidget>();
export class MarkdownWidget extends WidgetType {
renderedMarkdown?: string;
public dom?: HTMLElement;
constructor(
readonly from: number | undefined,
readonly client: Client,
readonly cacheKey: string,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
readonly className: string,
@@ -26,11 +25,12 @@ export class MarkdownWidget extends WidgetType {
toDOM(): HTMLElement {
const div = document.createElement("div");
div.className = this.className;
const cacheItem = this.client.getWidgetCache(this.bodyText);
const cacheItem = this.client.getWidgetCache(this.cacheKey);
if (cacheItem) {
div.innerHTML = this.wrapHtml(
cacheItem.html,
cacheItem.buttons,
cacheItem.buttons || [],
cacheItem.banner,
);
this.attachListeners(div, cacheItem.buttons);
}
@@ -55,7 +55,7 @@ export class MarkdownWidget extends WidgetType {
if (!widgetContent) {
div.innerHTML = "";
this.client.setWidgetCache(
this.bodyText,
this.cacheKey,
{ height: div.clientHeight, html: "" },
);
return;
@@ -73,8 +73,6 @@ export class MarkdownWidget extends WidgetType {
this.client.currentPage,
],
);
// Used for the source button
this.renderedMarkdown = renderToText(mdTree);
const html = renderMarkdownToHtml(mdTree, {
// Annotate every element with its position so we can use it to put
@@ -97,27 +95,48 @@ export class MarkdownWidget extends WidgetType {
// HTML still same as in cache, no need to re-render
return;
}
div.innerHTML = this.wrapHtml(html, widgetContent.buttons);
div.innerHTML = this.wrapHtml(
html,
widgetContent.buttons || [],
widgetContent.banner,
);
this.attachListeners(div, widgetContent.buttons);
// Let's give it a tick, then measure and cache
setTimeout(() => {
this.client.setWidgetCache(
this.bodyText,
{ height: div.offsetHeight, html, buttons: widgetContent.buttons },
this.cacheKey,
{
height: div.offsetHeight,
html,
buttons: widgetContent.buttons,
banner: widgetContent.banner,
},
);
// Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly
this.client.editorView.dispatch({
selection: {
anchor: this.client.editorView.state.selection.main.anchor,
},
});
});
}
private wrapHtml(html: string, buttons?: CodeWidgetButton[]) {
if (!buttons) {
return html;
private wrapHtml(
html: string,
buttons: CodeWidgetButton[],
banner?: string,
) {
if (!html) {
return "";
}
return `<div class="button-bar">${
buttons.map((button, idx) =>
buttons.filter((button) => !button.widgetTarget).map((button, idx) =>
`<button data-button="${idx}" title="${button.description}">${button.svg}</button> `
).join("")
}</div>${html}`;
}</div>${
banner ? `<div class="sb-banner">${escapeHtml(banner)}</div>` : ""
}${html}`;
}
private attachListeners(div: HTMLElement, buttons?: CodeWidgetButton[]) {
@@ -126,6 +145,7 @@ export class MarkdownWidget extends WidgetType {
// Override default click behavior with a local navigate (faster)
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const [pageName, pos] = el.dataset.ref!.split(/[$@]/);
if (pos && pos.match(/^\d+$/)) {
this.client.navigate(pageName, +pos);
@@ -138,9 +158,18 @@ export class MarkdownWidget extends WidgetType {
// Implement task toggling
div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => {
const taskRef = el.dataset.externalTaskRef;
el.querySelector("input[type=checkbox]").addEventListener(
const input = el.querySelector("input[type=checkbox]")!;
input.addEventListener(
"click",
(e: any) => {
// Avoid triggering the click on the parent
e.stopPropagation();
},
);
input.addEventListener(
"change",
(e: any) => {
e.stopPropagation();
const oldState = e.target.dataset.state;
const newState = oldState === " " ? "x" : " ";
// Update state in DOM as well for future toggles
@@ -162,29 +191,37 @@ export class MarkdownWidget extends WidgetType {
for (let i = 0; i < buttons.length; i++) {
const button = buttons[i];
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
() => {
console.log("Button clicked:", button.description);
if (button.widgetTarget) {
div.addEventListener("click", () => {
console.log("Widget clicked");
this.client.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
]).catch(console.error);
});
} else {
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
(e) => {
e.stopPropagation();
console.log("Button clicked:", button.description);
this.client.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
}
}
// div.querySelectorAll("ul > li").forEach((el) => {
// el.classList.add("sb-line-li-1", "sb-line-ul");
// });
}
get estimatedHeight(): number {
const cacheItem = this.client.getWidgetCache(this.bodyText);
const cacheItem = this.client.getWidgetCache(this.cacheKey);
// console.log("Calling estimated height", this.bodyText, cacheItem);
return cacheItem ? cacheItem.height : -1;
}
@@ -192,7 +229,7 @@ export class MarkdownWidget extends WidgetType {
eq(other: WidgetType): boolean {
return (
other instanceof MarkdownWidget &&
other.bodyText === this.bodyText
other.bodyText === this.bodyText && other.cacheKey === this.cacheKey
);
}
}
@@ -218,3 +255,10 @@ function garbageCollectWidgets() {
}
setInterval(garbageCollectWidgets, 5000);
function escapeHtml(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(
/>/g,
"&gt;",
);
}
+1
View File
@@ -8,6 +8,7 @@ const straightQuoteContexts = [
"FrontMatterCode",
"Attribute",
"CommandLink",
"TemplateDirective",
];
// TODO: Add support for selection (put quotes around or create blockquote block?)
+2
View File
@@ -17,6 +17,7 @@ export function postScriptPrefacePlugin(
undefined,
editor,
`top:${editor.currentPage}`,
"",
topCallback,
"sb-markdown-top-widget",
),
@@ -33,6 +34,7 @@ export function postScriptPrefacePlugin(
undefined,
editor,
`bottom:${editor.currentPage}`,
"",
bottomCallback,
"sb-markdown-bottom-widget",
),
+3 -2
View File
@@ -166,8 +166,9 @@ export function shouldRenderAsCode(
if (mainSelection.empty) {
return checkRangeOverlap(range, [mainSelection.from, mainSelection.to]);
} else {
// If the selection is encompassing the fenced code we render as code
return checkRangeSubset([mainSelection.from, mainSelection.to], range);
// If the selection is encompassing the fenced code we render as code, or vice versa
return checkRangeSubset([mainSelection.from, mainSelection.to], range) ||
checkRangeSubset(range, [mainSelection.from, mainSelection.to]);
}
}