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

Support inline smiles #57

Merged
merged 10 commits into from
Feb 2, 2024
4 changes: 2 additions & 2 deletions src/SmilesBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ export class SmilesBlock extends MarkdownRenderChild {

const isDQL = (source: string): boolean => {
const prefix = gDataview.settings.inlineQueryPrefix;
return source.startsWith(prefix);
return prefix.length > 0 && source.startsWith(prefix);
};
const isDataviewJs = (source: string): boolean => {
const prefix = gDataview.settings.inlineJsQueryPrefix;
return source.startsWith(prefix);
return prefix.length > 0 && source.startsWith(prefix);
};
const evaluateDQL = (row: string): string => {
const prefix = gDataview.settings.inlineQueryPrefix;
Expand Down
315 changes: 315 additions & 0 deletions src/SmilesInline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
/*
Adapted from https://github.com/blacksmithgu/obsidian-dataview/blob/master/src/ui/lp-render.ts
Refered to https://github.com/blacksmithgu/obsidian-dataview/pull/1247
More upstream from https://github.com/artisticat1/obsidian-latex-suite/blob/main/src/editor_extensions/conceal.ts
*/

import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view';
import { EditorSelection, Range } from '@codemirror/state';
import { syntaxTree, tokenClassNodeProp } from '@codemirror/language';
import { ChemPluginSettings } from './settings/base';

import { Component, editorInfoField, editorLivePreviewField } from 'obsidian';
import { SyntaxNode } from '@lezer/common';

import { gDrawer } from './global/drawer';
import { i18n } from './lib/i18n';

function selectionAndRangeOverlap(
selection: EditorSelection,
rangeFrom: number,
rangeTo: number
) {
for (const range of selection.ranges) {
if (range.from <= rangeTo && range.to >= rangeFrom) {
return true;
}
}
return false;
}

class InlineWidget extends WidgetType {
constructor(
readonly source: string,
private el: HTMLElement,
private view: EditorView
) {
super();
}

eq(other: InlineWidget): boolean {
return other.source === this.source ? true : false;
}

toDOM(): HTMLElement {
return this.el;
}

// TODO: adjust this behavior
/* Make queries only editable when shift is pressed (or navigated inside with the keyboard
* or the mouse is placed at the end, but that is always possible regardless of this method).
* Mostly useful for links, and makes results selectable.
* If the widgets should always be expandable, make this always return false.
*/
ignoreEvent(event: MouseEvent | Event): boolean {
// instanceof check does not work in pop-out windows, so check it like this
if (event.type === 'mousedown') {
const currentPos = this.view.posAtCoords({
x: (event as MouseEvent).x,
y: (event as MouseEvent).y,
});
if ((event as MouseEvent).shiftKey) {
// Set the cursor after the element so that it doesn't select starting from the last cursor position.
if (currentPos) {
const { editor } = this.view.state.field(editorInfoField);
if (editor) {
editor.setCursor(editor.offsetToPos(currentPos));
}
}
return false;
}
}
return true;
}
}

export function inlinePlugin(settings: ChemPluginSettings) {
const renderCell = (source: string, target: HTMLElement, theme: string) => {
const svg = target.createSvg('svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('data-smiles', source);

const errorCb = (
error: object & { name: string; message: string },
container: HTMLDivElement
) => {
container
.createDiv('error-source')
.setText(i18n.t('errors.source.title', { source }));
container.createEl('br');
const info = container.createEl('details');
info.createEl('summary').setText(error.name);
info.createEl('div').setText(error.message);

container.style.wordBreak = `break-word`;
container.style.userSelect = `text`;
};

gDrawer.draw(
source,
svg,
theme,
null,
(error: object & { name: string; message: string }) => {
target.empty();
errorCb(error, target.createEl('div'));
}
);
if (settings.options.scale == 0)
svg.style.width = `${settings.imgWidth.toString()}px`;
return svg;
};

return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
component: Component;

constructor(view: EditorView) {
this.component = new Component();
this.component.load();
this.decorations = this.inlineRender(view) ?? Decoration.none;
}

update(update: ViewUpdate) {
// only activate in LP and not source mode
if (!update.state.field(editorLivePreviewField)) {
this.decorations = Decoration.none;
return;
}
if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
this.updateTree(update.view);
} else if (update.selectionSet) {
this.updateTree(update.view);
} else if (update.viewportChanged /*|| update.selectionSet*/) {
this.decorations =
this.inlineRender(update.view) ?? Decoration.none;
}
}

updateTree(view: EditorView) {
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ node }) => {
const { render, isQuery } = this.renderNode(
view,
node
);
if (!render && isQuery) {
this.removeDeco(node);
return;
} else if (!render) {
return;
} else if (render) {
this.addDeco(node, view);
}
},
});
}
}

removeDeco(node: SyntaxNode) {
this.decorations.between(
node.from - 1,
node.to + 1,
(from, to, value) => {
this.decorations = this.decorations.update({
filterFrom: from,
filterTo: to,
filter: (from, to, value) => false,
});
}
);
}

addDeco(node: SyntaxNode, view: EditorView) {
const from = node.from - 1;
const to = node.to + 1;
let exists = false;
this.decorations.between(from, to, (from, to, value) => {
exists = true;
});
if (!exists) {
/**
* In a note embedded in a Canvas, app.workspace.getActiveFile() returns
* the canvas file, not the note file. On the other hand,
* view.state.field(editorInfoField).file returns the note file itself,
* which is more suitable here.
*/
const currentFile = view.state.field(editorInfoField).file;
if (!currentFile) return;
const newDeco = this.renderWidget(node, view)?.value;
if (newDeco) {
this.decorations = this.decorations.update({
add: [{ from: from, to: to, value: newDeco }],
});
}
}
}

// checks whether a node should get rendered/unrendered
renderNode(view: EditorView, node: SyntaxNode) {
const type = node.type;
const tokenProps = type.prop<string>(tokenClassNodeProp);
const props = new Set(tokenProps?.split(' '));
if (props.has('inline-code') && !props.has('formatting')) {
const start = node.from;
const end = node.to;
const selection = view.state.selection;
if (
selectionAndRangeOverlap(selection, start - 1, end + 1)
) {
if (this.isInlineSmiles(view, start, end)) {
return { render: false, isQuery: true };
} else {
return { render: false, isQuery: false };
}
} else if (this.isInlineSmiles(view, start, end)) {
return { render: true, isQuery: true };
}
}
return { render: false, isQuery: false };
}

isInlineSmiles(view: EditorView, start: number, end: number) {
if (settings.inlineSmilesPrefix.length > 0) {
const text = view.state.doc.sliceString(start, end);
return text.startsWith(settings.inlineSmilesPrefix);
} else return false;
}

inlineRender(view: EditorView) {
const currentFile = view.state.field(editorInfoField).file;
if (!currentFile) return;

const widgets: Range<Decoration>[] = [];

for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ node }) => {
if (!this.renderNode(view, node).render) return;
const widget = this.renderWidget(node, view);
if (widget) {
widgets.push(widget);
}
},
});
}
return Decoration.set(widgets, true);
}

renderWidget(node: SyntaxNode, view: EditorView) {
// contains the position of inline code
const start = node.from;
const end = node.to;
// safety net against unclosed inline code
if (view.state.doc.sliceString(end, end + 1) === '\n') {
return;
}
const text = view.state.doc.sliceString(start, end);
const el = createSpan({
cls: ['smiles', 'chem-cell-inline', 'chem-cell'],
});
/* If the query result is predefined text (e.g. in the case of errors), set innerText to it.
* Otherwise, pass on an empty element and fill it in later.
* This is necessary because {@link InlineWidget.toDOM} is synchronous but some rendering
* asynchronous.
*/

let code = '';
if (text.startsWith(settings.inlineSmilesPrefix)) {
if (settings.inlineSmiles) {
// TODO move validation forward, ensure to call native renderer when no smiles
code = text
.substring(settings.inlineSmilesPrefix.length)
.trim();

renderCell(
code,
el.createDiv(),
document.body.hasClass('theme-dark') &&
!document.body.hasClass('theme-light')
? settings.darkTheme
: settings.lightTheme
);
}
} else {
return;
}

return Decoration.replace({
widget: new InlineWidget(code, el, view),
inclusive: false,
block: false,
}).range(start - 1, end + 1);
}

destroy() {
this.component.unload();
}
},
{ decorations: (v) => v.decorations }
);
}
15 changes: 13 additions & 2 deletions src/lib/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,19 @@
"dataview": {
"title": "Dataview",
"enable": {
"name": "Inline Dataview",
"description": "Recognize and get return values from Dataview queries and DataviewJS lines as rendering source according to Dataview settings."
"name": "Parse Dataview",
"description": "In smiles block, recognize and get return values from Dataview queries and DataviewJS lines as rendering source according to Dataview settings."
}
},
"inline": {
"title": "Inline SMILES",
"enable": {
"name": "Enable inline SMILES",
"description": "Render SMILES code lines."
},
"prefix": {
"name": "Inline SMILES Prefix",
"description": "The prefix to inline SMILES."
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@
"name": "解析 Dataview",
"description": "根据 Dataview 插件设置,识别并执行 smiles 代码块中的 Dataview 查询式 (Queries) 和 DataviewJS 代码,依查询结果渲染结构。"
}
},
"inline": {
"title": "行内 SMILES 渲染",
"enable": {
"name": "启用行内 SMILES",
"description": "渲染行内代码形式的 SMILES 字符串。"
},
"prefix": {
"name": "前缀",
"description": "行内 SMILES 的前缀。"
}
}
}
}
Expand Down
Loading
Loading