Skip to content

Commit

Permalink
Fix MathJax inline fields flickering in Live Preview (#2089)
Browse files Browse the repository at this point in the history
* Fix renderCompactMarkdown causing MathJax Flickering

* fix formatting

* Rewrite the decoration updates for inline fields rendering to eliminate the flickering mathjax issue

---------

Co-authored-by: RyotaUshio <ushio@ms.k.u-tokyo.ac.jp>
  • Loading branch information
RyotaUshio and RyotaUshio authored Oct 9, 2023
1 parent 96a110a commit 7a2b6a1
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 92 deletions.
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

0 comments on commit 7a2b6a1

Please sign in to comment.