From 172587f1586c112664ed3a8b512bd7c5e2e79dce Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Wed, 17 Jan 2024 11:47:39 +0800 Subject: [PATCH 1/9] Handle empty settings object --- src/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.ts b/src/main.ts index 9757431..47533bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,8 @@ export default class ChemPlugin extends Plugin { const candidate = Object.assign({}, await this.loadData()); if ('version' in candidate && candidate.version == SETTINGS_VERSION) this.settings = Object.assign({}, DEFAULT_SETTINGS, candidate); + else if (Object.keys(candidate).length === 0) + this.settings = Object.assign({}, DEFAULT_SETTINGS); else this.settings = Object.assign( {}, From b4c3169207d2722beb7ed8a296438d80bc72275f Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Thu, 25 Jan 2024 15:52:01 +0800 Subject: [PATCH 2/9] check prefix availability --- src/SmilesBlock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SmilesBlock.ts b/src/SmilesBlock.ts index ad993c3..687ecc9 100644 --- a/src/SmilesBlock.ts +++ b/src/SmilesBlock.ts @@ -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; From 5c2bc9110ffd7f3d1e2a409ebdf365f03f01ead1 Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Thu, 25 Jan 2024 15:53:11 +0800 Subject: [PATCH 3/9] Add settings for inline smiles --- src/settings/SettingTab.ts | 28 ++++++++++++++++++++++++++++ src/settings/base.ts | 4 ++++ src/settings/update.ts | 4 ++++ 3 files changed, 36 insertions(+) diff --git a/src/settings/SettingTab.ts b/src/settings/SettingTab.ts index ad34d5e..b14bf1b 100644 --- a/src/settings/SettingTab.ts +++ b/src/settings/SettingTab.ts @@ -250,6 +250,34 @@ export class ChemSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl).setName('Inline').setHeading(); + + new Setting(containerEl) + .setName('Enable inline smiles') + .setDesc('desc') + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.inlineSmiles) + .onChange(async (value) => { + this.plugin.settings.inlineSmiles = value; + await this.plugin.saveSettings(); + onSettingsChange(); + }); + }); + + new Setting(containerEl) + .setName('Inline smiles prefix') + .setDesc('desc') + .addText((text) => { + text.setValue(this.plugin.settings.inlineSmilesPrefix).onChange( + async (value) => { + this.plugin.settings.inlineSmilesPrefix = value; + await this.plugin.saveSettings(); + onSettingsChange(); + } + ); + }); + const onSettingsChange = () => { preview.updateSettings(this.plugin.settings); preview.render(); diff --git a/src/settings/base.ts b/src/settings/base.ts index 2eb3406..59a4904 100644 --- a/src/settings/base.ts +++ b/src/settings/base.ts @@ -18,6 +18,8 @@ export interface ChemPluginSettings { theme: string; }; dataview: boolean; + inlineSmiles: boolean; + inlineSmilesPrefix: string; options: Partial; } @@ -34,6 +36,8 @@ export const DEFAULT_SETTINGS: ChemPluginSettings = { theme: 'default', }, dataview: false, + inlineSmiles: false, + inlineSmilesPrefix: '$smiles=', options: {}, }; diff --git a/src/settings/update.ts b/src/settings/update.ts index 00511de..d8bad03 100644 --- a/src/settings/update.ts +++ b/src/settings/update.ts @@ -25,6 +25,8 @@ interface ChemPluginSettingsV2 { theme: string; }; dataview: boolean; + inlineSmiles: boolean; + inlineSmilesPrefix: string; options: object; } @@ -42,6 +44,8 @@ const DEFAULT_SETTINGS_V2: ChemPluginSettingsV2 = { theme: 'default', }, dataview: false, + inlineSmiles: false, + inlineSmilesPrefix: '$smiles=', options: {}, }; From 768c62dc3b1a93f6e8a010bf0c2f2e62461d43e3 Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Thu, 25 Jan 2024 17:10:25 +0800 Subject: [PATCH 4/9] Add markdown postprocessor for inline code --- src/main.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/main.ts b/src/main.ts index 47533bd..5f9d294 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,6 +28,7 @@ export default class ChemPlugin extends Plugin { this.addSettingTab(new ChemSettingTab({ app: this.app, plugin: this })); this.registerMarkdownCodeBlockProcessor('smiles', this.smilesProcessor); + this.registerMarkdownPostProcessor(this.inlineSmilesProcessor); if (this.settings.dataview) getDataview(); } @@ -63,4 +64,20 @@ export default class ChemPlugin extends Plugin { ) => { ctx.addChild(new SmilesBlock(el, source, ctx, this.settings)); }; + + inlineSmilesProcessor = ( + el: HTMLElement, + ctx: MarkdownPostProcessorContext + ) => { + const inlineCodes = el.findAll('code'); + inlineCodes.forEach((code) => { + const text = code.innerText; + if (text.startsWith(this.settings.inlineSmilesPrefix)) { + const source = text + .substring(this.settings.inlineSmilesPrefix.length) + .trim(); + ctx.addChild(new SmilesBlock(code, source, ctx, this.settings)); + } + }); + }; } From 703a9cd5694dd90a0ca81dcb60d430a23f51ad46 Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Wed, 31 Jan 2024 17:30:50 +0800 Subject: [PATCH 5/9] Add editor extension for inline smiles --- src/SmilesInline.ts | 316 ++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 4 + 2 files changed, 320 insertions(+) create mode 100644 src/SmilesInline.ts diff --git a/src/SmilesInline.ts b/src/SmilesInline.ts new file mode 100644 index 0000000..72d28aa --- /dev/null +++ b/src/SmilesInline.ts @@ -0,0 +1,316 @@ +/* + 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(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) { + const text = view.state.doc.sliceString(start, end); + return text.startsWith(settings.inlineSmilesPrefix); + } + + inlineRender(view: EditorView) { + const currentFile = view.state.field(editorInfoField).file; + if (!currentFile) return; + + const widgets: Range[] = []; + + 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) { + const type = node.type; + // 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); + let code: string = ''; + 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. + */ + 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; + } + + const tokenProps = type.prop(tokenClassNodeProp); + const props = new Set(tokenProps?.split(' ')); + + 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 } + ); +} diff --git a/src/main.ts b/src/main.ts index 5f9d294..d0d48a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { import { ChemSettingTab } from './settings/SettingTab'; import { updateSettingsVersion } from './settings/update'; import { SmilesBlock } from './SmilesBlock'; +import { inlinePlugin } from './SmilesInline'; import { setBlocks, clearBlocks } from './global/blocks'; import { setDrawer, clearDrawer } from './global/drawer'; @@ -25,6 +26,9 @@ export default class ChemPlugin extends Plugin { setDrawer(this.settings.options); setBlocks(); setObserver(); + // editor extension + this.registerEditorExtension(inlinePlugin(this.settings)); + this.app.workspace.updateOptions(); this.addSettingTab(new ChemSettingTab({ app: this.app, plugin: this })); this.registerMarkdownCodeBlockProcessor('smiles', this.smilesProcessor); From c25a4b0b5d9876a369e756ad89b1bfa8942890ac Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Wed, 31 Jan 2024 17:31:41 +0800 Subject: [PATCH 6/9] Replace `code` tag with post processed inlineEl --- src/main.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index d0d48a4..ec9ed88 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,6 +73,7 @@ export default class ChemPlugin extends Plugin { el: HTMLElement, ctx: MarkdownPostProcessorContext ) => { + //https://docs.obsidian.md/Plugins/Editor/Markdown+post+processing const inlineCodes = el.findAll('code'); inlineCodes.forEach((code) => { const text = code.innerText; @@ -80,7 +81,11 @@ export default class ChemPlugin extends Plugin { const source = text .substring(this.settings.inlineSmilesPrefix.length) .trim(); - ctx.addChild(new SmilesBlock(code, source, ctx, this.settings)); + const container = el.createDiv(); + code.replaceWith(container); + ctx.addChild( + new SmilesBlock(container, source, ctx, this.settings) + ); } }); }; From cdca0f15df2fd96d8cba937dbe00ee18f76c37c8 Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Fri, 2 Feb 2024 11:57:18 +0800 Subject: [PATCH 7/9] Add i18n for inline smiles settings --- src/lib/translations/en.json | 15 +++++++++++++-- src/lib/translations/zh-CN.json | 11 +++++++++++ src/settings/SettingTab.ts | 12 +++++++----- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/lib/translations/en.json b/src/lib/translations/en.json index c65a446..23d24f6 100644 --- a/src/lib/translations/en.json +++ b/src/lib/translations/en.json @@ -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." } } } diff --git a/src/lib/translations/zh-CN.json b/src/lib/translations/zh-CN.json index 474e3a3..33c6d95 100644 --- a/src/lib/translations/zh-CN.json +++ b/src/lib/translations/zh-CN.json @@ -79,6 +79,17 @@ "name": "解析 Dataview", "description": "根据 Dataview 插件设置,识别并执行 smiles 代码块中的 Dataview 查询式 (Queries) 和 DataviewJS 代码,依查询结果渲染结构。" } + }, + "inline": { + "title": "行内 SMILES 渲染", + "enable": { + "name": "启用行内 SMILES", + "description": "渲染行内代码形式的 SMILES 字符串。" + }, + "prefix": { + "name": "前缀", + "description": "行内 SMILES 的前缀。" + } } } } diff --git a/src/settings/SettingTab.ts b/src/settings/SettingTab.ts index b14bf1b..c5748a6 100644 --- a/src/settings/SettingTab.ts +++ b/src/settings/SettingTab.ts @@ -250,11 +250,13 @@ export class ChemSettingTab extends PluginSettingTab { }); }); - new Setting(containerEl).setName('Inline').setHeading(); + new Setting(containerEl) + .setName(i18n.t('settings.inline.title')) + .setHeading(); new Setting(containerEl) - .setName('Enable inline smiles') - .setDesc('desc') + .setName(i18n.t('settings.inline.enable.name')) + .setDesc(i18n.t('settings.inline.enable.description')) .addToggle((toggle) => { toggle .setValue(this.plugin.settings.inlineSmiles) @@ -266,8 +268,8 @@ export class ChemSettingTab extends PluginSettingTab { }); new Setting(containerEl) - .setName('Inline smiles prefix') - .setDesc('desc') + .setName(i18n.t('settings.inline.prefix.name')) + .setDesc(i18n.t('settings.inline.prefix.description')) .addText((text) => { text.setValue(this.plugin.settings.inlineSmilesPrefix).onChange( async (value) => { From 8ba34cf0ffec9c69c11acf328d9bcfe568c412ad Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Fri, 2 Feb 2024 12:30:41 +0800 Subject: [PATCH 8/9] Satisfy lint --- src/SmilesInline.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/SmilesInline.ts b/src/SmilesInline.ts index 72d28aa..e30539c 100644 --- a/src/SmilesInline.ts +++ b/src/SmilesInline.ts @@ -210,7 +210,7 @@ export function inlinePlugin(settings: ChemPluginSettings) { // checks whether a node should get rendered/unrendered renderNode(view: EditorView, node: SyntaxNode) { const type = node.type; - const tokenProps = type.prop(tokenClassNodeProp); + const tokenProps = type.prop(tokenClassNodeProp); const props = new Set(tokenProps?.split(' ')); if (props.has('inline-code') && !props.has('formatting')) { const start = node.from; @@ -259,7 +259,6 @@ export function inlinePlugin(settings: ChemPluginSettings) { } renderWidget(node: SyntaxNode, view: EditorView) { - const type = node.type; // contains the position of inline code const start = node.from; const end = node.to; @@ -268,7 +267,6 @@ export function inlinePlugin(settings: ChemPluginSettings) { return; } const text = view.state.doc.sliceString(start, end); - let code: string = ''; const el = createSpan({ cls: ['smiles', 'chem-cell-inline', 'chem-cell'], }); @@ -277,6 +275,8 @@ export function inlinePlugin(settings: ChemPluginSettings) { * 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 @@ -297,9 +297,6 @@ export function inlinePlugin(settings: ChemPluginSettings) { return; } - const tokenProps = type.prop(tokenClassNodeProp); - const props = new Set(tokenProps?.split(' ')); - return Decoration.replace({ widget: new InlineWidget(code, el, view), inclusive: false, From 4f3a2702eda1b1c05652ab7b77a453601fa29775 Mon Sep 17 00:00:00 2001 From: Acylation <532117255@qq.com> Date: Fri, 2 Feb 2024 12:30:59 +0800 Subject: [PATCH 9/9] Update prefix validation --- src/SmilesInline.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/SmilesInline.ts b/src/SmilesInline.ts index e30539c..820b213 100644 --- a/src/SmilesInline.ts +++ b/src/SmilesInline.ts @@ -232,8 +232,10 @@ export function inlinePlugin(settings: ChemPluginSettings) { } isInlineSmiles(view: EditorView, start: number, end: number) { - const text = view.state.doc.sliceString(start, end); - return text.startsWith(settings.inlineSmilesPrefix); + if (settings.inlineSmilesPrefix.length > 0) { + const text = view.state.doc.sliceString(start, end); + return text.startsWith(settings.inlineSmilesPrefix); + } else return false; } inlineRender(view: EditorView) {