Skip to content

Commit

Permalink
Enable rendering of inline fields in Live Preview (#2083)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
RyotaUshio and RyotaUshio authored Oct 6, 2023
1 parent 3d9b0d2 commit 2606b28
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
177 changes: 177 additions & 0 deletions src/ui/views/inline-field-live-preview.ts
Original file line number Diff line number Diff line change
@@ -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<InlineFieldValue> {
const builder = new RangeSetBuilder<InlineFieldValue>();

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<RangeSet<InlineFieldValue>>({
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<Decoration>();
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<NodeList | null> {
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
}

0 comments on commit 2606b28

Please sign in to comment.