Skip to content

Commit 2612039

Browse files
authored
Support inline smiles (#57)
* 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
1 parent b463490 commit 2612039

File tree

8 files changed

+405
-4
lines changed

8 files changed

+405
-4
lines changed

src/SmilesBlock.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ export class SmilesBlock extends MarkdownRenderChild {
7575

7676
const isDQL = (source: string): boolean => {
7777
const prefix = gDataview.settings.inlineQueryPrefix;
78-
return source.startsWith(prefix);
78+
return prefix.length > 0 && source.startsWith(prefix);
7979
};
8080
const isDataviewJs = (source: string): boolean => {
8181
const prefix = gDataview.settings.inlineJsQueryPrefix;
82-
return source.startsWith(prefix);
82+
return prefix.length > 0 && source.startsWith(prefix);
8383
};
8484
const evaluateDQL = (row: string): string => {
8585
const prefix = gDataview.settings.inlineQueryPrefix;

src/SmilesInline.ts

+315
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
/*
2+
Adapted from https://github.com/blacksmithgu/obsidian-dataview/blob/master/src/ui/lp-render.ts
3+
Refered to https://github.com/blacksmithgu/obsidian-dataview/pull/1247
4+
More upstream from https://github.com/artisticat1/obsidian-latex-suite/blob/main/src/editor_extensions/conceal.ts
5+
*/
6+
7+
import {
8+
Decoration,
9+
DecorationSet,
10+
EditorView,
11+
ViewPlugin,
12+
ViewUpdate,
13+
WidgetType,
14+
} from '@codemirror/view';
15+
import { EditorSelection, Range } from '@codemirror/state';
16+
import { syntaxTree, tokenClassNodeProp } from '@codemirror/language';
17+
import { ChemPluginSettings } from './settings/base';
18+
19+
import { Component, editorInfoField, editorLivePreviewField } from 'obsidian';
20+
import { SyntaxNode } from '@lezer/common';
21+
22+
import { gDrawer } from './global/drawer';
23+
import { i18n } from './lib/i18n';
24+
25+
function selectionAndRangeOverlap(
26+
selection: EditorSelection,
27+
rangeFrom: number,
28+
rangeTo: number
29+
) {
30+
for (const range of selection.ranges) {
31+
if (range.from <= rangeTo && range.to >= rangeFrom) {
32+
return true;
33+
}
34+
}
35+
return false;
36+
}
37+
38+
class InlineWidget extends WidgetType {
39+
constructor(
40+
readonly source: string,
41+
private el: HTMLElement,
42+
private view: EditorView
43+
) {
44+
super();
45+
}
46+
47+
eq(other: InlineWidget): boolean {
48+
return other.source === this.source ? true : false;
49+
}
50+
51+
toDOM(): HTMLElement {
52+
return this.el;
53+
}
54+
55+
// TODO: adjust this behavior
56+
/* Make queries only editable when shift is pressed (or navigated inside with the keyboard
57+
* or the mouse is placed at the end, but that is always possible regardless of this method).
58+
* Mostly useful for links, and makes results selectable.
59+
* If the widgets should always be expandable, make this always return false.
60+
*/
61+
ignoreEvent(event: MouseEvent | Event): boolean {
62+
// instanceof check does not work in pop-out windows, so check it like this
63+
if (event.type === 'mousedown') {
64+
const currentPos = this.view.posAtCoords({
65+
x: (event as MouseEvent).x,
66+
y: (event as MouseEvent).y,
67+
});
68+
if ((event as MouseEvent).shiftKey) {
69+
// Set the cursor after the element so that it doesn't select starting from the last cursor position.
70+
if (currentPos) {
71+
const { editor } = this.view.state.field(editorInfoField);
72+
if (editor) {
73+
editor.setCursor(editor.offsetToPos(currentPos));
74+
}
75+
}
76+
return false;
77+
}
78+
}
79+
return true;
80+
}
81+
}
82+
83+
export function inlinePlugin(settings: ChemPluginSettings) {
84+
const renderCell = (source: string, target: HTMLElement, theme: string) => {
85+
const svg = target.createSvg('svg');
86+
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
87+
svg.setAttribute('data-smiles', source);
88+
89+
const errorCb = (
90+
error: object & { name: string; message: string },
91+
container: HTMLDivElement
92+
) => {
93+
container
94+
.createDiv('error-source')
95+
.setText(i18n.t('errors.source.title', { source }));
96+
container.createEl('br');
97+
const info = container.createEl('details');
98+
info.createEl('summary').setText(error.name);
99+
info.createEl('div').setText(error.message);
100+
101+
container.style.wordBreak = `break-word`;
102+
container.style.userSelect = `text`;
103+
};
104+
105+
gDrawer.draw(
106+
source,
107+
svg,
108+
theme,
109+
null,
110+
(error: object & { name: string; message: string }) => {
111+
target.empty();
112+
errorCb(error, target.createEl('div'));
113+
}
114+
);
115+
if (settings.options.scale == 0)
116+
svg.style.width = `${settings.imgWidth.toString()}px`;
117+
return svg;
118+
};
119+
120+
return ViewPlugin.fromClass(
121+
class {
122+
decorations: DecorationSet;
123+
component: Component;
124+
125+
constructor(view: EditorView) {
126+
this.component = new Component();
127+
this.component.load();
128+
this.decorations = this.inlineRender(view) ?? Decoration.none;
129+
}
130+
131+
update(update: ViewUpdate) {
132+
// only activate in LP and not source mode
133+
if (!update.state.field(editorLivePreviewField)) {
134+
this.decorations = Decoration.none;
135+
return;
136+
}
137+
if (update.docChanged) {
138+
this.decorations = this.decorations.map(update.changes);
139+
this.updateTree(update.view);
140+
} else if (update.selectionSet) {
141+
this.updateTree(update.view);
142+
} else if (update.viewportChanged /*|| update.selectionSet*/) {
143+
this.decorations =
144+
this.inlineRender(update.view) ?? Decoration.none;
145+
}
146+
}
147+
148+
updateTree(view: EditorView) {
149+
for (const { from, to } of view.visibleRanges) {
150+
syntaxTree(view.state).iterate({
151+
from,
152+
to,
153+
enter: ({ node }) => {
154+
const { render, isQuery } = this.renderNode(
155+
view,
156+
node
157+
);
158+
if (!render && isQuery) {
159+
this.removeDeco(node);
160+
return;
161+
} else if (!render) {
162+
return;
163+
} else if (render) {
164+
this.addDeco(node, view);
165+
}
166+
},
167+
});
168+
}
169+
}
170+
171+
removeDeco(node: SyntaxNode) {
172+
this.decorations.between(
173+
node.from - 1,
174+
node.to + 1,
175+
(from, to, value) => {
176+
this.decorations = this.decorations.update({
177+
filterFrom: from,
178+
filterTo: to,
179+
filter: (from, to, value) => false,
180+
});
181+
}
182+
);
183+
}
184+
185+
addDeco(node: SyntaxNode, view: EditorView) {
186+
const from = node.from - 1;
187+
const to = node.to + 1;
188+
let exists = false;
189+
this.decorations.between(from, to, (from, to, value) => {
190+
exists = true;
191+
});
192+
if (!exists) {
193+
/**
194+
* In a note embedded in a Canvas, app.workspace.getActiveFile() returns
195+
* the canvas file, not the note file. On the other hand,
196+
* view.state.field(editorInfoField).file returns the note file itself,
197+
* which is more suitable here.
198+
*/
199+
const currentFile = view.state.field(editorInfoField).file;
200+
if (!currentFile) return;
201+
const newDeco = this.renderWidget(node, view)?.value;
202+
if (newDeco) {
203+
this.decorations = this.decorations.update({
204+
add: [{ from: from, to: to, value: newDeco }],
205+
});
206+
}
207+
}
208+
}
209+
210+
// checks whether a node should get rendered/unrendered
211+
renderNode(view: EditorView, node: SyntaxNode) {
212+
const type = node.type;
213+
const tokenProps = type.prop<string>(tokenClassNodeProp);
214+
const props = new Set(tokenProps?.split(' '));
215+
if (props.has('inline-code') && !props.has('formatting')) {
216+
const start = node.from;
217+
const end = node.to;
218+
const selection = view.state.selection;
219+
if (
220+
selectionAndRangeOverlap(selection, start - 1, end + 1)
221+
) {
222+
if (this.isInlineSmiles(view, start, end)) {
223+
return { render: false, isQuery: true };
224+
} else {
225+
return { render: false, isQuery: false };
226+
}
227+
} else if (this.isInlineSmiles(view, start, end)) {
228+
return { render: true, isQuery: true };
229+
}
230+
}
231+
return { render: false, isQuery: false };
232+
}
233+
234+
isInlineSmiles(view: EditorView, start: number, end: number) {
235+
if (settings.inlineSmilesPrefix.length > 0) {
236+
const text = view.state.doc.sliceString(start, end);
237+
return text.startsWith(settings.inlineSmilesPrefix);
238+
} else return false;
239+
}
240+
241+
inlineRender(view: EditorView) {
242+
const currentFile = view.state.field(editorInfoField).file;
243+
if (!currentFile) return;
244+
245+
const widgets: Range<Decoration>[] = [];
246+
247+
for (const { from, to } of view.visibleRanges) {
248+
syntaxTree(view.state).iterate({
249+
from,
250+
to,
251+
enter: ({ node }) => {
252+
if (!this.renderNode(view, node).render) return;
253+
const widget = this.renderWidget(node, view);
254+
if (widget) {
255+
widgets.push(widget);
256+
}
257+
},
258+
});
259+
}
260+
return Decoration.set(widgets, true);
261+
}
262+
263+
renderWidget(node: SyntaxNode, view: EditorView) {
264+
// contains the position of inline code
265+
const start = node.from;
266+
const end = node.to;
267+
// safety net against unclosed inline code
268+
if (view.state.doc.sliceString(end, end + 1) === '\n') {
269+
return;
270+
}
271+
const text = view.state.doc.sliceString(start, end);
272+
const el = createSpan({
273+
cls: ['smiles', 'chem-cell-inline', 'chem-cell'],
274+
});
275+
/* If the query result is predefined text (e.g. in the case of errors), set innerText to it.
276+
* Otherwise, pass on an empty element and fill it in later.
277+
* This is necessary because {@link InlineWidget.toDOM} is synchronous but some rendering
278+
* asynchronous.
279+
*/
280+
281+
let code = '';
282+
if (text.startsWith(settings.inlineSmilesPrefix)) {
283+
if (settings.inlineSmiles) {
284+
// TODO move validation forward, ensure to call native renderer when no smiles
285+
code = text
286+
.substring(settings.inlineSmilesPrefix.length)
287+
.trim();
288+
289+
renderCell(
290+
code,
291+
el.createDiv(),
292+
document.body.hasClass('theme-dark') &&
293+
!document.body.hasClass('theme-light')
294+
? settings.darkTheme
295+
: settings.lightTheme
296+
);
297+
}
298+
} else {
299+
return;
300+
}
301+
302+
return Decoration.replace({
303+
widget: new InlineWidget(code, el, view),
304+
inclusive: false,
305+
block: false,
306+
}).range(start - 1, end + 1);
307+
}
308+
309+
destroy() {
310+
this.component.unload();
311+
}
312+
},
313+
{ decorations: (v) => v.decorations }
314+
);
315+
}

src/lib/translations/en.json

+13-2
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,19 @@
7676
"dataview": {
7777
"title": "Dataview",
7878
"enable": {
79-
"name": "Inline Dataview",
80-
"description": "Recognize and get return values from Dataview queries and DataviewJS lines as rendering source according to Dataview settings."
79+
"name": "Parse Dataview",
80+
"description": "In smiles block, recognize and get return values from Dataview queries and DataviewJS lines as rendering source according to Dataview settings."
81+
}
82+
},
83+
"inline": {
84+
"title": "Inline SMILES",
85+
"enable": {
86+
"name": "Enable inline SMILES",
87+
"description": "Render SMILES code lines."
88+
},
89+
"prefix": {
90+
"name": "Inline SMILES Prefix",
91+
"description": "The prefix to inline SMILES."
8192
}
8293
}
8394
}

src/lib/translations/zh-CN.json

+11
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@
7979
"name": "解析 Dataview",
8080
"description": "根据 Dataview 插件设置,识别并执行 smiles 代码块中的 Dataview 查询式 (Queries) 和 DataviewJS 代码,依查询结果渲染结构。"
8181
}
82+
},
83+
"inline": {
84+
"title": "行内 SMILES 渲染",
85+
"enable": {
86+
"name": "启用行内 SMILES",
87+
"description": "渲染行内代码形式的 SMILES 字符串。"
88+
},
89+
"prefix": {
90+
"name": "前缀",
91+
"description": "行内 SMILES 的前缀。"
92+
}
8293
}
8394
}
8495
}

0 commit comments

Comments
 (0)