From 2606b28d758a88161a4743dd532c9aed7e73bee8 Mon Sep 17 00:00:00 2001 From: Ryota Ushio <72342591+RyotaUshio@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:57:40 +0900 Subject: [PATCH] Enable rendering of inline fields in Live Preview (#2083) * enable rendering of inline fields in live preview * Use MarkdownView as the component that manages the lifecycle of rendered children instead of the plugin instance * Add comments * Modify a comment about editorInfoField --------- Co-authored-by: RyotaUshio --- src/main.ts | 4 + src/ui/views/inline-field-live-preview.ts | 177 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/ui/views/inline-field-live-preview.ts diff --git a/src/main.ts b/src/main.ts index 69209c73..c9358a06 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { currentLocale } from "util/locale"; import { DateTime } from "luxon"; import { DataviewInlineApi } from "api/inline-api"; import { replaceInlineFields } from "ui/views/inline-field"; +import { inlineFieldsField, replaceInlineFieldsInLivePreview } from "./ui/views/inline-field-live-preview"; import { DataviewInit } from "ui/markdown"; import { inlinePlugin } from "./ui/lp-render"; import { Extension } from "@codemirror/state"; @@ -119,6 +120,9 @@ export default class DataviewPlugin extends Plugin { // Not required anymore, though holding onto it for backwards-compatibility. this.app.metadataCache.trigger("dataview:api-ready", this.api); console.log(`Dataview: version ${this.manifest.version} (requires obsidian ${this.manifest.minAppVersion})`); + + this.registerEditorExtension(inlineFieldsField); + this.registerEditorExtension(replaceInlineFieldsInLivePreview(this.app)); } private debouncedRefresh: () => void = () => null; diff --git a/src/ui/views/inline-field-live-preview.ts b/src/ui/views/inline-field-live-preview.ts new file mode 100644 index 00000000..bc24999d --- /dev/null +++ b/src/ui/views/inline-field-live-preview.ts @@ -0,0 +1,177 @@ +import { App, Component, MarkdownRenderer, editorInfoField } from "obsidian"; +import { EditorState, RangeSet, RangeSetBuilder, RangeValue, StateField } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView, PluginValue, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view"; +import { InlineField, extractInlineFields } from "data-import/inline-field"; +import { canonicalizeVarName } from "util/normalize"; + + +class InlineFieldValue extends RangeValue { + constructor(public field: InlineField) { + super(); + } +} + +function buildInlineFields(state: EditorState): RangeSet { + const builder = new RangeSetBuilder(); + + for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber++) { + const line = state.doc.line(lineNumber); + const inlineFields = extractInlineFields(line.text); + for (const field of inlineFields) { + builder.add(line.from + field.start, line.from + field.end, new InlineFieldValue(field)) + } + } + return builder.finish(); +} + +/** A state field that stores the inline fields and their positions as a range set. */ +export const inlineFieldsField = StateField.define>({ + create: buildInlineFields, + update(oldFields, tr) { + return tr.docChanged ? buildInlineFields(tr.state) : oldFields; + } +}); + +/** Create a view plugin that renders inline fields in live preview just as in the reading view. */ +export const replaceInlineFieldsInLivePreview = (app: App) => ViewPlugin.fromClass( + class implements PluginValue { + decorations: DecorationSet; + overlappingIndices: number[]; + + constructor(view: EditorView) { + this.decorations = this.buildDecoration(view); + this.overlappingIndices = this.getOverlappingIndices(view.state); + } + + update(update: ViewUpdate): void { + // To reduce the total number of updating the decorations, we only update if + // the state of overlapping (i.e. which inline field is overlapping with the cursor) has changed + // except when the document has changed or the viewport has changed. + + const oldIndices = this.overlappingIndices; + const newIndices = this.getOverlappingIndices(update.state); + + let overlapChanged = + update.startState.field(inlineFieldsField).size != update.state.field(inlineFieldsField).size + || JSON.stringify(oldIndices) != JSON.stringify(newIndices) + + this.overlappingIndices = newIndices; + + if (update.docChanged || update.viewportChanged || overlapChanged) { + this.decorations = this.buildDecoration(update.view); + } + } + + buildDecoration(view: EditorView): DecorationSet { + const markdownView = view.state.field(editorInfoField); + if (!(markdownView instanceof Component)) { + // For a canvas card not assosiated with a note in the vault, + // editorInfoField is not MarkdownView, which inherits from the Component class. + // A component object is required to pass to MarkdownRenderer.render. + return Decoration.none; + } + + const file = markdownView.file; + if (!file) return Decoration.none; + + const info = view.state.field(inlineFieldsField); + const builder = new RangeSetBuilder(); + const selection = view.state.selection.main; + + let x = 0; + for (const { from, to } of view.visibleRanges) { + info.between(from, to, (start, end, { field }) => { + // If the inline field is not overlapping with the cursor, we replace it with a widget. + if (start > selection.to || end < selection.from) { + builder.add( + start, + end, + Decoration.replace({ + widget: new InlineFieldWidget(app, field, x++, file.path, markdownView), + }) + ); + } + }); + } + return builder.finish(); + } + + getOverlappingIndices(state: EditorState): number[] { + const selection = state.selection.main; + const cursor = state.field(inlineFieldsField).iter(); + const indices: number[] = []; + let i = 0; + while (cursor.value) { + if (cursor.from <= selection.to && cursor.to >= selection.from) { + indices.push(i); + } + cursor.next(); + i++; + } + return indices; + } + }, { + decorations: instance => instance.decorations, +}); + +/** A widget which inline fields are replaced with. */ +class InlineFieldWidget extends WidgetType { + constructor(public app: App, public field: InlineField, public id: number, public sourcePath: string, public parentComponent: Component) { + super(); + } + + toDOM() { + // A large part of this method was taken from replaceInlineFields() in src/ui/views/inline-field.tsx. + // It will be better to extract the common part as a function... + + const renderContainer = createSpan({ + cls: ["dataview", "inline-field"], + }); + + // Block inline fields render the key, parenthesis ones do not. + if (this.field.wrapping == "[") { + const key = renderContainer.createSpan({ + cls: ["dataview", "inline-field-key"], + attr: { + "data-dv-key": this.field.key, + "data-dv-norm-key": canonicalizeVarName(this.field.key), + }, + }); + + // Explicitly set the inner HTML to respect any key formatting that we should carry over. + this.renderMarkdown(key, this.field.key); + + const value = renderContainer.createSpan({ + cls: ["dataview", "inline-field-value"], + attr: { id: "dataview-inline-field-" + this.id }, + }); + this.renderMarkdown(value, this.field.value); + } else { + const value = renderContainer.createSpan({ + cls: ["dataview", "inline-field-standalone-value"], + attr: { id: "dataview-inline-field-" + this.id }, + }); + this.renderMarkdown(value, this.field.value); + } + + return renderContainer; + } + + async renderMarkdown(el: HTMLElement, source: string) { + const children = await renderMarkdown(this.app, source, this.sourcePath, this.parentComponent); + if (children) + el.replaceChildren(...children); + } +} + +/** Easy-to-use version of MarkdownRenderer.render. Returns only the child nodes intead of a container block. */ +export async function renderMarkdown(app: App, markdown: string, sourcePath: string, component: Component): Promise { + const el = createSpan(); + await MarkdownRenderer.render(app, markdown, el, sourcePath, component); + for (const child of el.children) { + if (child.tagName == "P") { + return child.childNodes; + } + } + return null +}