From b463490d60420478c5777044382fa2ccb49b7a8b Mon Sep 17 00:00:00 2001 From: Chiyu Liang <532117255@qq.com> Date: Tue, 30 Jan 2024 15:44:20 +0800 Subject: [PATCH 1/3] Handle empty settings object (#54) --- 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 2612039a9fcedb72e1405552caf2f1183f7bbd1e Mon Sep 17 00:00:00 2001 From: Chiyu Liang <532117255@qq.com> Date: Fri, 2 Feb 2024 14:21:19 +0800 Subject: [PATCH 2/3] 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 --- src/SmilesBlock.ts | 4 +- src/SmilesInline.ts | 315 ++++++++++++++++++++++++++++++++ src/lib/translations/en.json | 15 +- src/lib/translations/zh-CN.json | 11 ++ src/main.ts | 26 +++ src/settings/SettingTab.ts | 30 +++ src/settings/base.ts | 4 + src/settings/update.ts | 4 + 8 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 src/SmilesInline.ts 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; diff --git a/src/SmilesInline.ts b/src/SmilesInline.ts new file mode 100644 index 0000000..820b213 --- /dev/null +++ b/src/SmilesInline.ts @@ -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(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[] = []; + + 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 } + ); +} 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/main.ts b/src/main.ts index 47533bd..ec9ed88 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,9 +26,13 @@ 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); + this.registerMarkdownPostProcessor(this.inlineSmilesProcessor); if (this.settings.dataview) getDataview(); } @@ -63,4 +68,25 @@ export default class ChemPlugin extends Plugin { ) => { ctx.addChild(new SmilesBlock(el, source, ctx, this.settings)); }; + + inlineSmilesProcessor = ( + 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; + if (text.startsWith(this.settings.inlineSmilesPrefix)) { + const source = text + .substring(this.settings.inlineSmilesPrefix.length) + .trim(); + const container = el.createDiv(); + code.replaceWith(container); + ctx.addChild( + new SmilesBlock(container, source, ctx, this.settings) + ); + } + }); + }; } diff --git a/src/settings/SettingTab.ts b/src/settings/SettingTab.ts index ad34d5e..c5748a6 100644 --- a/src/settings/SettingTab.ts +++ b/src/settings/SettingTab.ts @@ -250,6 +250,36 @@ export class ChemSettingTab extends PluginSettingTab { }); }); + new Setting(containerEl) + .setName(i18n.t('settings.inline.title')) + .setHeading(); + + new Setting(containerEl) + .setName(i18n.t('settings.inline.enable.name')) + .setDesc(i18n.t('settings.inline.enable.description')) + .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(i18n.t('settings.inline.prefix.name')) + .setDesc(i18n.t('settings.inline.prefix.description')) + .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 85a07c434284a84d4029b39c2aea371e521daa7e Mon Sep 17 00:00:00 2001 From: Chiyu Liang <532117255@qq.com> Date: Fri, 2 Feb 2024 17:05:40 +0800 Subject: [PATCH 3/3] Add options for explicit hydrogen (#58) --- src/lib/translations/en.json | 4 ++++ src/lib/translations/zh-CN.json | 4 ++++ src/settings/SettingTab.ts | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/src/lib/translations/en.json b/src/lib/translations/en.json index 23d24f6..eb6bba5 100644 --- a/src/lib/translations/en.json +++ b/src/lib/translations/en.json @@ -55,6 +55,10 @@ "terminal-carbons": { "name": "Show terminal carbons", "description": "Explictly draw terminal carbons like methyl or methylene." + }, + "explicit-hydrogen": { + "name": "Explicit hydrogen", + "description": "Enable to show explicit hydrogen." } }, "copy": { diff --git a/src/lib/translations/zh-CN.json b/src/lib/translations/zh-CN.json index 33c6d95..6e68ec1 100644 --- a/src/lib/translations/zh-CN.json +++ b/src/lib/translations/zh-CN.json @@ -55,6 +55,10 @@ "terminal-carbons": { "name": "端基碳", "description": "显式绘出末端甲基、亚甲基等端基碳。" + }, + "explicit-hydrogen": { + "name": "显式氢", + "description": "启用以绘制显式氢原子。" } }, "copy": { diff --git a/src/settings/SettingTab.ts b/src/settings/SettingTab.ts index c5748a6..c5b73ac 100644 --- a/src/settings/SettingTab.ts +++ b/src/settings/SettingTab.ts @@ -181,6 +181,25 @@ export class ChemSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName(i18n.t('settings.advanced.explicit-hydrogen.name')) + .setDesc(i18n.t('settings.advanced.explicit-hydrogen.description')) + .addToggle((toggle) => + toggle + .setValue( + this.plugin.settings.options?.explicitHydrogens ?? false + ) + .onChange(async (value) => { + this.plugin.settings.options.explicitHydrogens = value; + await this.plugin.saveSettings(); + setDrawer({ + ...DEFAULT_SD_OPTIONS, + ...this.plugin.settings.options, + }); + onSettingsChange(); + }) + ); + new Setting(containerEl) .setName(i18n.t('settings.copy.title')) .setHeading();