Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix MathJax inline fields flickering in Live Preview #2089

Merged
merged 4 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ui/lp-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { DataviewInlineApi } from "../api/inline-api";
import { renderValue } from "./render";
import { SyntaxNode } from "@lezer/common";

function selectionAndRangeOverlap(selection: EditorSelection, rangeFrom: number, rangeTo: number) {
export function selectionAndRangeOverlap(selection: EditorSelection, rangeFrom: number, rangeTo: number) {
for (const range of selection.ranges) {
if (range.from <= rangeTo && range.to >= rangeFrom) {
return true;
Expand Down
15 changes: 7 additions & 8 deletions src/ui/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ export async function renderCompactMarkdown(
sourcePath: string,
component: Component
) {
let subcontainer = container.createSpan();
await MarkdownRenderer.renderMarkdown(markdown, subcontainer, sourcePath, component);
const tmpContainer = createSpan();
await MarkdownRenderer.renderMarkdown(markdown, tmpContainer, sourcePath, component);

let paragraph = subcontainer.querySelector(":scope > p");
if (subcontainer.children.length == 1 && paragraph) {
while (paragraph.firstChild) {
subcontainer.appendChild(paragraph.firstChild);
}
subcontainer.removeChild(paragraph);
let paragraph = tmpContainer.querySelector(":scope > p");
if (tmpContainer.childNodes.length == 1 && paragraph) {
container.replaceChildren(...paragraph.childNodes);
}

tmpContainer.remove();
}

/** Render a pre block with an error in it; returns the element to allow for dynamic updating. */
Expand Down
177 changes: 94 additions & 83 deletions src/ui/views/inline-field-live-preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { App, Component, MarkdownRenderer, editorInfoField, editorLivePreviewField } from "obsidian";
import { App, Component, TFile, editorInfoField, editorLivePreviewField } from "obsidian";
import { EditorState, RangeSet, RangeSetBuilder, RangeValue, StateEffect, StateField } from "@codemirror/state";
import {
Decoration,
Expand All @@ -11,13 +11,18 @@ import {
} from "@codemirror/view";
import { InlineField, extractInlineFields, parseInlineValue } from "data-import/inline-field";
import { canonicalizeVarName } from "util/normalize";
import { renderValue } from "ui/render";
import { renderCompactMarkdown, renderValue } from "ui/render";
import { DataviewSettings } from "settings";
import { selectionAndRangeOverlap } from "ui/lp-render";

class InlineFieldValue extends RangeValue {
constructor(public field: InlineField) {
super();
}

eq(other: InlineFieldValue): boolean {
return this.field.key == other.field.key && this.field.value == other.field.value;
}
}

function buildInlineFields(state: EditorState): RangeSet<InlineFieldValue> {
Expand Down Expand Up @@ -46,69 +51,38 @@ export const replaceInlineFieldsInLivePreview = (app: App, settings: DataviewSet
ViewPlugin.fromClass(
class implements PluginValue {
decorations: DecorationSet;
overlappingIndices: number[];
component: Component;

constructor(view: EditorView) {
this.decorations = this.buildDecoration(view);
this.overlappingIndices = this.getOverlappingIndices(view.state);
this.component = new Component();
this.component.load();
this.decorations = this.buildDecorations(view);
}

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);

const overlapChanged =
update.startState.field(inlineFieldsField).size != update.state.field(inlineFieldsField).size ||
JSON.stringify(oldIndices) != JSON.stringify(newIndices);

this.overlappingIndices = newIndices;

const layoutChanged = update.transactions.some(transaction =>
transaction.effects.some(effect => effect.is(workspaceLayoutChangeEffect))
);

if (update.state.field(editorLivePreviewField)) {
if (update.docChanged || update.viewportChanged || layoutChanged || overlapChanged) {
this.decorations = this.buildDecoration(update.view);
}
} else {
this.decorations = Decoration.none;
}
destroy() {
this.component.unload();
}

buildDecoration(view: EditorView): DecorationSet {
buildDecorations(view: EditorView): DecorationSet {
// Disable in the source mode
if (!view.state.field(editorLivePreviewField)) return Decoration.none;

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;
const file = view.state.field(editorInfoField).file;
if (!file) return Decoration.none;

const info = view.state.field(inlineFieldsField);
const builder = new RangeSetBuilder<Decoration>();
const selection = view.state.selection.main;
const selection = view.state.selection;

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) {
if (selectionAndRangeOverlap(selection, start, end)) {
builder.add(
start,
end,
Decoration.replace({
widget: new InlineFieldWidget(app, field, x++, file.path, markdownView, settings),
widget: new InlineFieldWidget(app, field, file.path, this.component, settings),
})
);
}
Expand All @@ -117,19 +91,78 @@ export const replaceInlineFieldsInLivePreview = (app: App, settings: DataviewSet
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++;
update(update: ViewUpdate) {
// only activate in LP and not source mode
if (!update.state.field(editorLivePreviewField)) {
this.decorations = Decoration.none;
return;
}

const layoutChanged = update.transactions.some(transaction =>
transaction.effects.some(effect => effect.is(workspaceLayoutChangeEffect))
);

if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
this.updateDecorations(update.view);
} else if (update.selectionSet) {
this.updateDecorations(update.view);
} else if (update.viewportChanged || layoutChanged) {
this.decorations = this.buildDecorations(update.view);
}
}

updateDecorations(view: EditorView) {
const file = view.state.field(editorInfoField).file;
if (!file) {
this.decorations = Decoration.none;
return;
}

const inlineFields = view.state.field(inlineFieldsField);
const selection = view.state.selection;

for (const { from, to } of view.visibleRanges) {
inlineFields.between(from, to, (start, end, { field }) => {
const overlap = selectionAndRangeOverlap(selection, start, end);
if (overlap) {
this.removeDeco(start, end);
return;
} else {
this.addDeco(start, end, field, file);
}
});
}
}

removeDeco(start: number, end: number) {
this.decorations.between(start, end, (from, to) => {
this.decorations = this.decorations.update({
filterFrom: from,
filterTo: to,
filter: () => false,
});
});
}

addDeco(start: number, end: number, field: InlineField, file: TFile) {
let exists = false;
this.decorations.between(start, end, () => {
exists = true;
});
if (!exists) {
this.decorations = this.decorations.update({
add: [
{
from: start,
to: end,
value: Decoration.replace({
widget: new InlineFieldWidget(app, field, file.path, this.component, settings),
}),
},
],
});
}
return indices;
}
},
{
Expand All @@ -142,14 +175,17 @@ class InlineFieldWidget extends WidgetType {
constructor(
public app: App,
public field: InlineField,
public id: number,
public sourcePath: string,
public parentComponent: Component,
public settings: DataviewSettings
) {
super();
}

eq(other: InlineFieldWidget): boolean {
return this.field.key == other.field.key && this.field.value == other.field.value;
}

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...
Expand All @@ -168,12 +204,10 @@ class InlineFieldWidget extends WidgetType {
},
});

// Explicitly set the inner HTML to respect any key formatting that we should carry over.
this.renderMarkdown(key, this.field.key);
renderCompactMarkdown(this.field.key, key, this.sourcePath, this.parentComponent);

const value = renderContainer.createSpan({
cls: ["dataview", "inline-field-value"],
attr: { id: "dataview-inline-field-" + this.id },
});
renderValue(
parseInlineValue(this.field.value),
Expand All @@ -186,7 +220,6 @@ class InlineFieldWidget extends WidgetType {
} else {
const value = renderContainer.createSpan({
cls: ["dataview", "inline-field-standalone-value"],
attr: { id: "dataview-inline-field-" + this.id },
});
renderValue(
parseInlineValue(this.field.value),
Expand All @@ -200,28 +233,6 @@ class InlineFieldWidget extends WidgetType {

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;
}

/**
Expand Down