Skip to content

Commit

Permalink
Support inline smiles (#57)
Browse files Browse the repository at this point in the history
* Handle empty settings object

* check prefix availability

* Add settings for inline smiles

* Add markdown postprocessor for inline code

* Add editor extension for inline smiles

* Replace `code` tag with post processed inlineEl

* Add i18n for inline smiles settings

* Satisfy lint

* Update prefix validation
  • Loading branch information
Acylation authored Feb 2, 2024
1 parent b463490 commit 2612039
Show file tree
Hide file tree
Showing 8 changed files with 405 additions and 4 deletions.
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

0 comments on commit 2612039

Please sign in to comment.