From 146e95cb23f197e20214e50b047c106ca5fd3b64 Mon Sep 17 00:00:00 2001 From: David Sehnal Date: Fri, 4 Jul 2025 10:29:03 +0200 Subject: [PATCH] Snapshot Markdown Improvements (#1555) * basic markdown commands * markdown renderers * support markdown tables * fix style * indicate external links in markdown * simplify the api * load image from MVSX * lint * docs * typo * custom color palette support * move manager to mol-plugin-state * customize args parser * better custom args parser support --- CHANGELOG.md | 7 + docs/docs/extensions/tunnels.md | 4 +- .../plugin/managers/markdown-extensions.md | 94 +++++ docs/mkdocs.yml | 4 +- package-lock.json | 359 ++++++++++++++++++ package.json | 1 + .../elements/snapshot-markdown.tsx | 5 +- src/apps/mvs-stories/styles.scss | 21 + src/extensions/mvs/behavior.ts | 37 +- src/extensions/mvs/helpers/utils.ts | 15 +- .../manager/markdown-extensions.ts | 248 ++++++++++++ src/mol-plugin-ui/controls.tsx | 26 +- src/mol-plugin-ui/controls/markdown.tsx | 141 +++++++ src/mol-plugin-ui/controls/parameters.tsx | 63 +-- .../skin/base/components/markdown.scss | 25 ++ src/mol-plugin-ui/skin/base/ui.scss | 2 + src/mol-plugin/context.ts | 2 + src/mol-util/color/utils.ts | 144 +++++++ src/mol-util/file.ts | 33 ++ 19 files changed, 1129 insertions(+), 102 deletions(-) create mode 100644 docs/docs/plugin/managers/markdown-extensions.md create mode 100644 src/mol-plugin-state/manager/markdown-extensions.ts create mode 100644 src/mol-plugin-ui/controls/markdown.tsx create mode 100644 src/mol-plugin-ui/skin/base/components/markdown.scss create mode 100644 src/mol-util/color/utils.ts create mode 100644 src/mol-util/file.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e62069222..f354599ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ Note that since we don't clearly distinguish between a public and private interf - Canvas node: support custom properties `molstar_enable_outline`, `molstar_enable_shadow`, `molstar_enable_ssao` - Renamed some color schemes ('inferno' -> 'inferno-no-black', 'magma' -> 'magma-no-black', 'turbo' -> 'turbo-no-black', 'rainbow' -> 'simple-rainbow') - Added new color schemes, synchronized with D3.js ('inferno', 'magma', 'turbo', 'rainbow', 'sinebow', 'warm', 'cool', 'cubehelix-default', 'category-10', 'observable-10', 'tableau-10') +- Snapshot Markdown improvements + - Add `MarkdownExtensionManager` (`PluginContext.managers.markdownExtensions`) + - Support custom markdown commands to control the plugin via the `[link](!command)` pattern + - Support rendering custom elements via the `![alt](!parameters)` pattern + - Support tables + - Support loading images from MVSX files + - Indicate external links with ⤴ - Avoid calculating rings for coarse-grained structures - Fix isosurface compute shader normals when transformation matrix is applied to volume - Breaking: `PluginContext.initViewer/initContainer/mount` are now async and have been renamed to include `Async` postfix diff --git a/docs/docs/extensions/tunnels.md b/docs/docs/extensions/tunnels.md index 816103751..acd66d2f1 100644 --- a/docs/docs/extensions/tunnels.md +++ b/docs/docs/extensions/tunnels.md @@ -94,7 +94,7 @@ The extension uses several transformations to process and visualize tunnel data: To help users understand how to use these transformations in practice, include detailed examples: ### Visualizing Multiple Tunnels -This example ([runVisualizeTunnels](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L19)) demonstrates how to visualize multiple tunnels from a fetched dataset. +This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L19`) demonstrates how to visualize multiple tunnels from a fetched dataset. ```typescript update.toRoot() .apply(TunnelsFromRawData, { data: tunnels }) @@ -104,7 +104,7 @@ update.toRoot() ``` ### Visualizing a Single Tunnel -This example ([runVisualizeTunnel](../../../src/extensions/sb-ncbr/tunnels/examples.ts#L46)) shows how to visualize a single tunnel. +This example (see `src/extensions/sb-ncbr/tunnels/examples.ts#L46`) shows how to visualize a single tunnel. ```typescript update.toRoot() .apply(TunnelFromRawData, { diff --git a/docs/docs/plugin/managers/markdown-extensions.md b/docs/docs/plugin/managers/markdown-extensions.md new file mode 100644 index 000000000..36128aa76 --- /dev/null +++ b/docs/docs/plugin/managers/markdown-extensions.md @@ -0,0 +1,94 @@ +# Markdown Extension Manager + +The `markdownExtensions` manager in `PluginContext.manager` allows customizing +the `Markdown` React component to enable executing commands and rendering custom content. + +The main use case of this is enriching [MolViewSpec](`https://molstar.org/mol-view-spec`) support. + +## API + +- `PluginContext.manager.markdownExtensions.register*` functions can be used to register extensions and state/data resolvers to make the the manager work with plugin extension +- `PluginContext.manager.markdownExtensions.remove*` can be used to dynamically remove the above + +## Commands + +Extends Markdown Hyperlink syntax to support expressions of the form `[title](!c1=v1&c2=v2&...)` into an executable command. The command can be executed either on click, mouse enter, or mouse leave. + +### Built-in Commands + +- `center-camera` - Centers the camera +- `apply-snapshot=key` - Loads snapshots with the provided key +- `focus-refs=ref1,ref2,...` - On click, focuses nodes with the provided refs +- `highlight-refs=ref1,ref2,...` - On mouse over, highlights the provided refs + +## Custom Content + +Extends Markdown Image syntax to support expressions of the form `![alt](!c1=v1&c2=v2&...)` to render custom elements instead. + +### Built-in Custom Content +- `color-swatch=color` - Renders a box with the provided color +- Color palettes: + - `color-palette-name=name` - Renders a gradient with the provivided named color palette (see `mol-util/color/lists.ts` for supported color schemes) + - `color-palette-colors=color1,color2` - Renders a gradient with the provided colors + - `color-palette-width=CCS-value` - Specifies the width of the element, defaults to `150px` + - `color-palette-height=CCS-value` - Specified the height of the element, defaults to `0.5em` + - `color-palette-discrete` - Renders discrete color list instead of interpolating + + +## Example + +```markdown +### Highlight/Focus: +- ![blue](!color-swatch=blue) [polymer](!highlight-refs=polymer&focus-refs=polymer) +- ![blue](!color-swatch=red) [ligand](!highlight-refs=ligand&focus-refs=ligand) +- [both](!highlight-refs=polymer,ligand&focus-refs=polymer,ligand) + +### Color Palettes +|name|visual| +|---:|---| +|viridis|![viridis](!color-palette-name=viridis)| +|rainbow (discrete)|![simple-rainbow](!color-palette-name=simple-rainbow&color-palette-discrete)| +|custom|![custom](!color-palette-colors=red,#00ff00,rgb(0,0,255))| + +### Camera controls +- [center](!center-camera) + +### Image embedded in MVSX file +![mvsx image](logo.png) +``` + +This works with the MolViewSpec state built by: + +```py +import molviewspec as mvs + +builder = mvs.create_builder() + +assets = { + "1cbs.cif": "https://files.wwpdb.org/download/1cbs.cif", + "logo.png": "https://molstar.org/img/molstar-logo.png", +} + +model = ( + builder.download(url="1cbs.cif") + .parse(format="mmcif") + .model_structure() +) +( + model.component(selector="polymer") + .representation(ref="polymer") + .color(color="blue") +) +( + model.component(selector="ligand") + .representation(ref="ligand") + .color(color="red") +) + +mvsx = mvs.MVSX( + data=builder.get_state( + description="""...""" # inline the code above + ), + assets=assets +) +``` \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 371668f15..520a299b7 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -38,6 +38,8 @@ nav: - Data State: 'plugin/data-state.md' - File Formats: 'plugin/file-formats.md' - CIF Schemas: 'plugin/cif-schemas.md' + - Managers: + - Markdown Extensions: 'plugin/managers/markdown-extensions.md' - State Transforms: - Custom Trajectory: 'plugin/transforms/custom-trajectory.md' - Custom Conformation: 'plugin/transforms/custom-conformation.md' @@ -59,5 +61,5 @@ nav: - Interactions: 'extensions/interactions.md' - Misc: - Interesting PDB entries: misc/interesting-pdb-entries.md - - Exporting component data: exporting-components.md + - Exporting component data: misc/exporting-components.md repo_url: https://github.com/molstar/docs diff --git a/package-lock.json b/package-lock.json index bd7db8640..f18ae5e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "io-ts": "^2.2.22", "node-fetch": "^2.7.0", "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "rxjs": "^7.8.2", "swagger-ui-dist": "^5.24.0", "tslib": "^2.8.1", @@ -4043,6 +4044,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8222,6 +8233,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8343,6 +8364,16 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8351,6 +8382,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", @@ -8374,6 +8433,121 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.0.2.tgz", @@ -8393,6 +8567,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-to-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", @@ -8507,6 +8702,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", @@ -10331,6 +10647,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -10362,6 +10696,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13247,6 +13596,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index a563029ef..3fc84d75f 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "io-ts": "^2.2.22", "node-fetch": "^2.7.0", "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "rxjs": "^7.8.2", "swagger-ui-dist": "^5.24.0", "tslib": "^2.8.1", diff --git a/src/apps/mvs-stories/elements/snapshot-markdown.tsx b/src/apps/mvs-stories/elements/snapshot-markdown.tsx index 860ad4695..c5dfeeef4 100644 --- a/src/apps/mvs-stories/elements/snapshot-markdown.tsx +++ b/src/apps/mvs-stories/elements/snapshot-markdown.tsx @@ -8,13 +8,12 @@ import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs'; import { PluginComponent } from '../../../mol-plugin-state/component'; import { getMVSStoriesContext, MVSStoriesContext } from '../context'; import { MVSStoriesViewerModel } from './viewer'; -import Markdown from 'react-markdown'; import { useBehavior } from '../../../mol-plugin-ui/hooks/use-behavior'; import { createRoot } from 'react-dom/client'; import { PluginStateSnapshotManager } from '../../../mol-plugin-state/manager/snapshots'; -import { MarkdownAnchor } from '../../../mol-plugin-ui/controls'; import { PluginReactContext } from '../../../mol-plugin-ui/base'; import { CSSProperties } from 'react'; +import { Markdown } from '../../../mol-plugin-ui/controls/markdown'; export class MVSStoriesSnapshotMarkdownModel extends PluginComponent { readonly context: MVSStoriesContext; @@ -99,7 +98,7 @@ export function MVSStoriesSnapshotMarkdownUI({ model }: { model: MVSStoriesSnaps
- {state.entry?.description ?? 'Description not available'} + {state.entry?.description ?? 'Description not available'}
diff --git a/src/apps/mvs-stories/styles.scss b/src/apps/mvs-stories/styles.scss index dd8ce57e9..5bb1db280 100644 --- a/src/apps/mvs-stories/styles.scss +++ b/src/apps/mvs-stories/styles.scss @@ -1,3 +1,5 @@ +@use '../../mol-plugin-ui/skin/base/components/markdown.scss'; + .mvs-stories-markdown-explanation { // Adapted from skeleton.css, The MIT License (MIT), Copyright (c) 2011-2014 Dave Gamache line-height: 1.4; @@ -161,6 +163,25 @@ border-width: 0; border-top: 1px solid #E1E1E1; } + + table { + border: 1px solid #E1E1E1; + border-collapse: collapse; + } + + th { + text-align: left; + } + + th, td { + border: 1px solid #E1E1E1; + padding: 4px 8px; + } + + img { + max-width: 100%; + height: auto; + } } @media (orientation:portrait) { diff --git a/src/extensions/mvs/behavior.ts b/src/extensions/mvs/behavior.ts index 845e4732f..51e6418de 100644 --- a/src/extensions/mvs/behavior.ts +++ b/src/extensions/mvs/behavior.ts @@ -12,9 +12,10 @@ import { LociLabelProvider } from '../../mol-plugin-state/manager/loci-label'; import { PluginBehavior } from '../../mol-plugin/behavior/behavior'; import { PluginContext } from '../../mol-plugin/context'; import { StructureRepresentationProvider } from '../../mol-repr/structure/representation'; -import { StateAction } from '../../mol-state'; +import { StateAction, StateObjectCell, StateTree } from '../../mol-state'; import { Task } from '../../mol-task'; import { ColorTheme } from '../../mol-theme/color'; +import { fileToDataUri } from '../../mol-util/file'; import { ParamDefinition as PD } from '../../mol-util/param-definition'; import { MVSAnnotationColorThemeProvider } from './components/annotation-color-theme'; import { MVSAnnotationLabelRepresentationProvider } from './components/annotation-label/representation'; @@ -109,6 +110,39 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({ for (const action of this.registrables.actions ?? []) { this.ctx.state.data.actions.add(action); } + + this.ctx.managers.markdownExtensions.registerRefResolver('mvs', (plugin, refs) => { + const mvsRefs = new Set(refs.map(ref => `mvs-ref:${ref}`)); + return StateTree.doPreOrder( + plugin.state.data.tree, + plugin.state.data.tree.root, + { mvsRefs, plugin, cells: [] as StateObjectCell[] }, + (n, _, s) => { + if (!n.tags) return; + for (const tag of n.tags) { + if (!s.mvsRefs.has(tag)) continue; + const cell = s.plugin.state.data.cells.get(n.ref); + if (cell) { + s.cells.push(cell); + break; + } + } + }).cells; + }); + + this.ctx.managers.markdownExtensions.registerUriResolver('mvs', (plugin, uri) => { + const { assets } = plugin.managers.asset; + const asset = assets.find(a => a.file.name === uri); + if (!asset) { + return undefined; + } + try { + return fileToDataUri(asset.file); + } catch (e) { + console.error(`MVS: Failed to convert asset file to data URI for '${uri}'`, e); + return undefined; + } + }); } update(p: { autoAttach: boolean }) { const updated = this.params.autoAttach !== p.autoAttach; @@ -146,6 +180,7 @@ export const MolViewSpec = PluginBehavior.create<{ autoAttach: boolean }>({ for (const action of this.registrables.actions ?? []) { this.ctx.state.data.actions.remove(action); } + this.ctx.managers.markdownExtensions.removeRefResolver('mvs'); } }, params: () => ({ diff --git a/src/extensions/mvs/helpers/utils.ts b/src/extensions/mvs/helpers/utils.ts index 53d933008..18b0439e6 100644 --- a/src/extensions/mvs/helpers/utils.ts +++ b/src/extensions/mvs/helpers/utils.ts @@ -9,6 +9,7 @@ import { hashString } from '../../../mol-data/util'; import { StateObject } from '../../../mol-state'; import { Color } from '../../../mol-util/color'; import { ColorNames } from '../../../mol-util/color/names'; +import { decodeColor as _decodeColor } from '../../../mol-util/color/utils'; /** Represents either the result or the reason of failure of an operation that might have failed */ @@ -100,19 +101,7 @@ export type ElementOfSet = S extends Set ? T : never /** Convert `colorString` (either X11 color name like 'magenta' or hex code like '#ff00ff') to Color. * Return `undefined` if `colorString` cannot be converted. */ export function decodeColor(colorString: string | undefined | null): Color | undefined { - if (colorString === undefined || colorString === null) return undefined; - let result: Color | undefined; - if (HexColor.is(colorString)) { - if (colorString.length === 4) { - // convert short form to full form (#f0f -> #ff00ff) - colorString = `#${colorString[1]}${colorString[1]}${colorString[2]}${colorString[2]}${colorString[3]}${colorString[3]}`; - } - result = Color.fromHexStyle(colorString); - if (result !== undefined && !isNaN(result)) return result; - } - result = ColorNames[colorString.toLowerCase() as keyof typeof ColorNames]; - if (result !== undefined) return result; - return undefined; + return _decodeColor(colorString); } /** Regular expression matching a hexadecimal color string, e.g. '#FF1100' or '#f10' */ diff --git a/src/mol-plugin-state/manager/markdown-extensions.ts b/src/mol-plugin-state/manager/markdown-extensions.ts new file mode 100644 index 000000000..9d7f248e1 --- /dev/null +++ b/src/mol-plugin-state/manager/markdown-extensions.ts @@ -0,0 +1,248 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal + */ + +import { getCellBoundingSphere } from '../../mol-plugin-state/manager/focus-camera/focus-object'; +import { PluginStateObject } from '../../mol-plugin-state/objects'; +import { StateObjectCell } from '../../mol-state'; +import { PluginContext } from '../../mol-plugin/context'; + +export type MarkdownExtensionEvent = 'click' | 'mouse-enter' | 'mouse-leave'; + +export interface MarkdownExtension { + name: string; + execute?: (options: { + event: MarkdownExtensionEvent, + args: Record, + manager: MarkdownExtensionManager + }) => void; + reactRenderFn?: (options: { + args: Record, + manager: MarkdownExtensionManager + }) => any; +} + +export const BuiltInMarkdownExtension: MarkdownExtension[] = [ + { + name: 'center-camera', + execute: ({ event, args, manager }) => { + if (event !== 'click') return; + if ('center-camera' in args) { + manager.plugin.managers.camera.reset(); + } + } + }, + { + name: 'apply-snapshot', + execute: ({ event, args, manager }) => { + if (event !== 'click') return; + const key = args['apply-snapshot']; + if (!key) return; + manager.plugin.managers.snapshot.applyKey(key); + } + }, + { + name: 'focus-refs', + execute: ({ event, args, manager }) => { + if (event !== 'click') return; + const refs = parseArray(args['focus-refs']); + if (!refs?.length) return; + + const cells = manager.findCells(refs); + if (!cells.length) return; + + const reprs = findRepresentations(manager.plugin, cells); + if (!reprs.length) return; + + const spheres = reprs.map(c => getCellBoundingSphere(manager.plugin, c.transform.ref)).filter(s => !!s); + if (!spheres.length) return; + manager.plugin.managers.camera.focusSpheres(spheres, s => s, { extraRadius: 3 }); + } + }, + { + name: 'highlight-refs', + execute: ({ event, args, manager }) => { + const refs = parseArray(args['highlight-refs']); + if (!refs?.length) return; + + if (event === 'mouse-leave' && refs.length) { + manager.plugin.managers.interactivity.lociHighlights.clearHighlights(); + return; + } else if (event === 'mouse-enter') { + const cells = manager.findCells(refs); + for (const cell of findRepresentations(manager.plugin, cells)) { + if (!cell.obj?.data) continue; + const { repr } = cell.obj.data; + for (const loci of repr.getAllLoci()) { + manager.plugin.managers.interactivity.lociHighlights.highlight({ loci, repr }, false); + } + } + } + } + }, +]; + +export class MarkdownExtensionManager { + private extension: MarkdownExtension[] = []; + private refResolvers: Record StateObjectCell[]> = { + default: (plugin: PluginContext, refs: string[]) => refs + .map(ref => plugin.state.data.cells.get(ref)) + .filter(c => !!c), + }; + private uriResolvers: Record Promise | string | undefined> = {}; + private argsParsers: [name: string, priority: number, parser: (input: string | undefined) => Record | undefined][] = [ + ['default', 100, defaultParseMarkdownCommandArgs], + ]; + + /** + * Default parser has priority 100, parsers with higher priority + * will be called first. + */ + registerArgsParser(name: string, priority: number, parser: (input: string | undefined) => Record | undefined) { + this.removeArgsParser(name); + this.argsParsers.push([name, priority, parser]); + this.argsParsers.sort((a, b) => b[1] - a[1]); // Sort by priority, higher first + } + + removeArgsParser(name: string) { + const idx = this.argsParsers.findIndex(p => p[0] === name); + if (idx >= 0) { + this.argsParsers.splice(idx, 1); + } + } + + parseArgs(input: string | undefined): Record | undefined { + for (const [,, parser] of this.argsParsers) { + const ret = parser(input); + if (ret) return ret; + } + return undefined; + } + + registerRefResolver(name: string, resolver: (plugin: PluginContext, refs: string[]) => StateObjectCell[]) { + this.refResolvers[name] = resolver; + } + + removeRefResolver(name: string) { + delete this.refResolvers[name]; + } + + registerUriResolver(name: string, resolver: (plugin: PluginContext, uri: string) => Promise | string | undefined) { + this.uriResolvers[name] = resolver; + } + + removeUriResolver(name: string) { + delete this.uriResolvers[name]; + } + + registerExtension(command: MarkdownExtension) { + const existing = this.extension.findIndex(c => c.name === command.name); + if (existing >= 0) { + this.extension[existing] = command; + } else { + this.extension.push(command); + } + } + + removeExtension(name: string) { + const idx = this.extension.findIndex(c => c.name === name); + if (idx >= 0) { + this.extension.splice(idx, 1); + } + } + + private _tryRender(ext: MarkdownExtension, options: { args: Record, manager: MarkdownExtensionManager }) { + try { + return ext.reactRenderFn?.(options); + } catch (e) { + console.error(`Failed to render markdown extension '${ext.name}'`, e); + return null; + } + } + + /** + * Render a markdown extension with the given arguments. + * Default renderers are defined separately because we + * don't want to include `react` outside of mol-plugin-ui. + */ + tryRender(args: Record, defaultRenderers: MarkdownExtension[]): any { + const options = { args, manager: this }; + for (const ext of this.extension) { + const ret = this._tryRender(ext, options); + if (ret) { + return ret; + } + } + for (const ext of defaultRenderers) { + const ret = this._tryRender(ext, options); + if (ret) { + return ret; + } + } + return null; + } + + tryExecute(event: MarkdownExtensionEvent, args: Record) { + const options = { event, args, manager: this }; + for (const ext of this.extension) { + try { + ext.execute?.(options); + } catch (e) { + console.error(`Failed to execute markdown extension '${ext.name}'`, e); + } + } + } + + tryResolveUri(uri: string): Promise | string | undefined { + for (const resolver of Object.values(this.uriResolvers)) { + const resolved = resolver(this.plugin, uri); + if (resolved) { + return resolved; + } + } + } + + findCells(refs: string[]): StateObjectCell[] { + const added = new Set(); + const ret: StateObjectCell[] = []; + for (const resolver of Object.values(this.refResolvers)) { + for (const cell of resolver(this.plugin, refs)) { + if (cell && !added.has(cell.transform.ref)) { + added.add(cell.transform.ref); + ret.push(cell); + } + } + } + return ret; + } + + constructor(public plugin: PluginContext) { + for (const command of BuiltInMarkdownExtension) { + this.registerExtension(command); + } + } +} + +function parseArray(input?: string): string[] { + return input?.split(',').map(s => s.trim()).filter(s => s.length > 0) ?? []; +} + +function findRepresentations(plugin: PluginContext, cells: StateObjectCell[]): StateObjectCell[] { + if (!cells.length) return []; + return plugin.state.data.selectQ(q => + q.byValue(...cells).subtree().filter(c => PluginStateObject.isRepresentation3D(c.obj)) + ); +} + +export function defaultParseMarkdownCommandArgs(input: string | undefined): Record | undefined { + if (!input?.startsWith('!')) return undefined; + const entries = decodeURIComponent(input.substring(1)) + .split('&') + .map(p => p.trim()) + .filter(p => p.length > 0) + .map(p => p.split('=', 2).map(s => s.trim())); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries); +} \ No newline at end of file diff --git a/src/mol-plugin-ui/controls.tsx b/src/mol-plugin-ui/controls.tsx index 3336c3dad..e773278a0 100644 --- a/src/mol-plugin-ui/controls.tsx +++ b/src/mol-plugin-ui/controls.tsx @@ -6,7 +6,7 @@ */ import * as React from 'react'; -import Markdown from 'react-markdown'; +import ReactMarkdown from 'react-markdown'; import { UpdateTrajectory } from '../mol-plugin-state/actions/structure'; import { LociLabel } from '../mol-plugin-state/manager/loci-label'; import { PluginStateObject } from '../mol-plugin-state/objects'; @@ -26,6 +26,7 @@ import { VolumeStreamingControls, VolumeSourceControls } from './structure/volum import { PluginConfig } from '../mol-plugin/config'; import { StructureSuperpositionControls } from './structure/superposition'; import { StructureQuickStylesControls } from './structure/quick-styles'; +import { Markdown } from './controls/markdown'; export class TrajectoryViewportControls extends PluginUIComponent<{}, { show: boolean, label: string }> { state = { show: false, label: '' }; @@ -210,30 +211,11 @@ export function ViewportSnapshotDescription() { return
{e.descriptionFormat === 'plaintext' && e.description - || {e.description} + || {e.description} }
; } -export function MarkdownAnchor({ href, children, element }: { href?: string, children?: any, element?: any }) { - const plugin = React.useContext(PluginReactContext); - - if (!href) return element; - - if (href[0] === '#') { - return { - e.preventDefault(); - plugin.managers.snapshot.applyKey(href.substring(1)); - }}>{children}; - } else if (href) { - return {children}; - } - - // TODO: consider adding more "commands", for example !reset-camera - - return children; -} - export class AnimationViewportControls extends PluginUIComponent<{}, { isEmpty: boolean, isExpanded: boolean, isBusy: boolean, isAnimating: boolean, isPlaying: boolean }> { state = { isEmpty: true, isExpanded: false, isBusy: false, isAnimating: false, isPlaying: false }; @@ -306,7 +288,7 @@ export class LociLabels extends PluginUIComponent<{}, { labels: ReadonlyArray { if (e.indexOf('\n') >= 0) { return
- {e} + {e}
; } diff --git a/src/mol-plugin-ui/controls/markdown.tsx b/src/mol-plugin-ui/controls/markdown.tsx new file mode 100644 index 000000000..0e0e3cddb --- /dev/null +++ b/src/mol-plugin-ui/controls/markdown.tsx @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal + */ + +import { useContext, useEffect, useState } from 'react'; +import ReactMarkdown, { Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { PluginReactContext } from '../base'; +import { PluginUIContext } from '../context'; +import { PluginContext } from '../../mol-plugin/context'; +import { MarkdownExtension } from '../../mol-plugin-state/manager/markdown-extensions'; +import { ColorLists } from '../../mol-util/color/lists'; +import { getColorGradient, getColorGradientBanded, parseColorList } from '../../mol-util/color/utils'; + +export function Markdown({ children, components }: { children?: string, components?: Components }) { + return
+ + {children} + +
; +} + +export function MarkdownImg({ src, element, alt }: { src?: string, element?: any, alt?: string }) { + const plugin: PluginUIContext | undefined = useContext(PluginReactContext); + + if (!src) return element; + + warnMissingPlugin(plugin); + const args = plugin?.managers.markdownExtensions.parseArgs(src); + if (args) { + const result = plugin?.managers.markdownExtensions.tryRender(args, DefaultMarkdownExtensionRenderers); + return result ?? element; + } else { + const data = plugin?.managers.markdownExtensions.tryResolveUri(src); + if (typeof (data as Promise)?.then === 'function') { + return } />; + } else if (typeof data === 'string' && data) { + return {alt}; + } + } + + return {alt}; +} + +function LazyStaticImg({ alt, data }: { alt?: string, data: Promise }) { + const [src, setSrc] = useState(undefined); + useEffect(() => { + let mounted = true; + data.then(d => { + if (mounted) setSrc(d); + }).catch(e => { + console.error('Failed to load static image', e); + if (mounted) setSrc(undefined); + }); + return () => { mounted = false; }; + }, [data]); + if (!src) return null; + return {alt}; +} + +export const DefaultMarkdownExtensionRenderers: MarkdownExtension[] = [ + { + name: 'color-swatch', + reactRenderFn: ({ args }) => { + const color = args['color-swatch']; + if (!color) return null; + return ; + } + }, + { + name: 'color-palette', + reactRenderFn: ({ args }) => { + const name = args['color-palette-name']; + const colors = args['color-palette-colors']; + const minWidth = args['color-palette-width'] ?? '150px'; + const height = args['color-palette-height'] ?? '0.5em'; + const discrete = 'color-palette-discrete' in args; + if (!name && !colors) return null; + + const list = colors + ? parseColorList(colors) + : ColorLists[name.toLowerCase() as keyof typeof ColorLists]?.list; + + if (!list?.length) { + console.warn(`Color palette could not be resolved.`, args); + return null; + } + + return ; + } + } +]; + +export function MarkdownAnchor({ href, children, element }: { href?: string, children?: any, element?: any }) { + const plugin: PluginUIContext | undefined = useContext(PluginReactContext); + + if (!href) return element; + + warnMissingPlugin(plugin); + const args = plugin?.managers.markdownExtensions.parseArgs(href); + + if (args) { + return { + e.preventDefault(); + plugin?.managers.markdownExtensions.tryExecute('click', args); + }} + onMouseEnter={() => plugin?.managers.markdownExtensions.tryExecute('mouse-enter', args)} + onMouseLeave={() => plugin?.managers.markdownExtensions.tryExecute('mouse-leave', args)} + > + {children} + ; + } else if (href[0] === '#') { + warnMissingPlugin(plugin); + return { + e.preventDefault(); + plugin?.managers.snapshot.applyKey(href.substring(1)); + }}>{children}; + } else if (href) { + return {children}⤴; + } + + return children; +} + +function warnMissingPlugin(plugin: PluginContext | undefined) { + if (plugin) return; + console.warn('Markdown component requires a PluginReactContext to be set.'); +} \ No newline at end of file diff --git a/src/mol-plugin-ui/controls/parameters.tsx b/src/mol-plugin-ui/controls/parameters.tsx index f2a684320..2e1a9f70d 100644 --- a/src/mol-plugin-ui/controls/parameters.tsx +++ b/src/mol-plugin-ui/controls/parameters.tsx @@ -29,6 +29,7 @@ import { ArrowDownwardSvg, ArrowDropDownSvg, ArrowRightSvg, ArrowUpwardSvg, Book import { legendFor } from './legend'; import { LineGraphComponent } from './line-graph/line-graph-component'; import { Slider, Slider2 } from './slider'; +import { getColorGradient, getColorGradientBanded } from '../../mol-util/color/utils'; export type ParameterControlsCategoryFilter = string | null | (string | null)[] @@ -666,67 +667,9 @@ export class ColorControl extends SimpleParam { } } -function colorEntryToStyle(e: ColorListEntry, includeOffset = false) { - if (Array.isArray(e)) { - if (includeOffset) return `${Color.toStyle(e[0])} ${(100 * e[1]).toFixed(2)}%`; - return Color.toStyle(e[0]); - } - return Color.toStyle(e); -} +const colorGradientInterpolated = memoize1(getColorGradient); -const colorGradientInterpolated = memoize1((colors: ColorListEntry[]) => { - if (colors.length === 0) return 'linear-gradient(to right, #000 0%, #000 100%)'; - - const hasOffsets = colors.every(c => Array.isArray(c)); - let styles; - - if (hasOffsets) { - const off = [...colors] as [Color, number][]; - off.sort((a, b) => a[1] - b[1]); - styles = off.map(c => colorEntryToStyle(c, true)); - } else { - styles = colors.map(c => colorEntryToStyle(c)); - } - - return `linear-gradient(to right, ${styles.join(', ')})`; -}); - -const colorGradientBanded = memoize1((colors: ColorListEntry[]) => { - const n = colors.length; - const styles: string[] = []; - - const hasOffsets = colors.every(c => Array.isArray(c)); - if (hasOffsets) { - const off = [...colors] as [Color, number][]; - // 0 colors present - if (!off[0]) { - return 'linear-gradient(to right, #000 0%, #000 100%)'; - } - off.sort((a, b) => a[1] - b[1]); - styles.push(`${Color.toStyle(off[0][0])} ${(100 * off[0][1]).toFixed(2)}%`); - for (let i = 0, il = off.length - 1; i < il; ++i) { - const [c0, o0] = off[i]; - const [c1, o1] = off[i + 1]; - const o = o0 + (o1 - o0) / 2; - styles.push( - `${Color.toStyle(c0)} ${(100 * o).toFixed(2)}%`, - `${Color.toStyle(c1)} ${(100 * o).toFixed(2)}%` - ); - } - styles.push(`${Color.toStyle(off[off.length - 1][0])} ${(100 * off[off.length - 1][1]).toFixed(2)}%`); - } else { - styles.push(`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`); - for (let i = 1, il = n - 1; i < il; ++i) { - styles.push( - `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, - `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` - ); - } - styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); - } - - return `linear-gradient(to right, ${styles.join(', ')})`; -}); +const colorGradientBanded = memoize1(getColorGradientBanded); function colorStripStyle(list: PD.ColorList['defaultValue'], right = '0'): React.CSSProperties { return { diff --git a/src/mol-plugin-ui/skin/base/components/markdown.scss b/src/mol-plugin-ui/skin/base/components/markdown.scss new file mode 100644 index 000000000..a32b34e37 --- /dev/null +++ b/src/mol-plugin-ui/skin/base/components/markdown.scss @@ -0,0 +1,25 @@ +@use '../vars' as *; +@use '../common' as *; + +@mixin markdown { + .msp-markdown { + table { + border: 1px solid $border-color; + border-collapse: collapse; + } + + th { + text-align: left; + } + + th, td { + border: 1px solid $border-color; + padding: 4px 8px; + } + + img { + max-width: 100%; + height: auto; + } + } +} \ No newline at end of file diff --git a/src/mol-plugin-ui/skin/base/ui.scss b/src/mol-plugin-ui/skin/base/ui.scss index a4c70a3dd..6129465ca 100644 --- a/src/mol-plugin-ui/skin/base/ui.scss +++ b/src/mol-plugin-ui/skin/base/ui.scss @@ -5,6 +5,7 @@ @use './components/line-graph' as *; @use './components/log' as *; @use './components/slider' as *; +@use './components/markdown' as *; @use './components/misc' as *; @use './components/tasks' as *; @use './components/viewport' as *; @@ -38,6 +39,7 @@ @include line-graph; @include log; @include slider; + @include markdown; @include misc; @include tasks; @include viewport; diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index d5440a7dc..aa84f0d19 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -30,6 +30,7 @@ import { StructureHierarchyRef } from '../mol-plugin-state/manager/structure/hie import { StructureMeasurementManager } from '../mol-plugin-state/manager/structure/measurement'; import { StructureSelectionManager } from '../mol-plugin-state/manager/structure/selection'; import { VolumeHierarchyManager } from '../mol-plugin-state/manager/volume/hierarchy'; +import { MarkdownExtensionManager } from '../mol-plugin-state/manager/markdown-extensions'; import { LeftPanelTabName, PluginLayout } from './layout'; import { Representation } from '../mol-repr/representation'; import { StructureRepresentationRegistry } from '../mol-repr/structure/registry'; @@ -190,6 +191,7 @@ export class PluginContext { toast: new PluginToastManager(this), asset: new AssetManager(), task: new TaskManager(), + markdownExtensions: new MarkdownExtensionManager(this), dragAndDrop: new DragAndDropManager(this), } as const; diff --git a/src/mol-util/color/utils.ts b/src/mol-util/color/utils.ts new file mode 100644 index 000000000..5e189e22f --- /dev/null +++ b/src/mol-util/color/utils.ts @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal + */ + +import { Color, ColorListEntry } from './color'; +import { ColorNames } from './names'; + +const hexColorRegex = /^#([0-9A-F]{3}){1,2}$/i; +const rgbColorRegex = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i; + +export function decodeColor(colorString: string | undefined | null): Color | undefined { + if (colorString === undefined || colorString === null) return undefined; + let result: Color | undefined; + if (hexColorRegex.test(colorString)) { + if (colorString.length === 4) { + // convert short form to full form (#f0f -> #ff00ff) + colorString = `#${colorString[1]}${colorString[1]}${colorString[2]}${colorString[2]}${colorString[3]}${colorString[3]}`; + } + result = Color.fromHexStyle(colorString); + if (result !== undefined && !isNaN(result)) return result; + } + + result = ColorNames[colorString.toLowerCase() as keyof typeof ColorNames]; + if (result !== undefined) return result; + + const rgbMatch = rgbColorRegex.exec(colorString); + if (rgbMatch) { + const r = parseInt(rgbMatch[1], 10); + const g = parseInt(rgbMatch[2], 10); + const b = parseInt(rgbMatch[3], 10); + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { + return Color.fromRgb(r, g, b); + } + } + + return undefined; +} + +export function getColorGradientBanded(colors: ColorListEntry[]) { + const n = colors.length; + const styles: string[] = []; + + const hasOffsets = colors.every(c => Array.isArray(c)); + if (hasOffsets) { + const off = [...colors] as [Color, number][]; + // 0 colors present + if (!off[0]) { + return 'linear-gradient(to right, #000 0%, #000 100%)'; + } + off.sort((a, b) => a[1] - b[1]); + styles.push(`${Color.toStyle(off[0][0])} ${(100 * off[0][1]).toFixed(2)}%`); + for (let i = 0, il = off.length - 1; i < il; ++i) { + const [c0, o0] = off[i]; + const [c1, o1] = off[i + 1]; + const o = o0 + (o1 - o0) / 2; + styles.push( + `${Color.toStyle(c0)} ${(100 * o).toFixed(2)}%`, + `${Color.toStyle(c1)} ${(100 * o).toFixed(2)}%` + ); + } + styles.push(`${Color.toStyle(off[off.length - 1][0])} ${(100 * off[off.length - 1][1]).toFixed(2)}%`); + } else { + styles.push(`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`); + for (let i = 1, il = n - 1; i < il; ++i) { + styles.push( + `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, + `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` + ); + } + styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); + } + + return `linear-gradient(to right, ${styles.join(', ')})`; +} + +export function getColorGradient(colors: ColorListEntry[]) { + if (colors.length === 0) return 'linear-gradient(to right, #000 0%, #000 100%)'; + + const hasOffsets = colors.every(c => Array.isArray(c)); + let styles; + + if (hasOffsets) { + const off = [...colors] as [Color, number][]; + off.sort((a, b) => a[1] - b[1]); + styles = off.map(c => colorEntryToStyle(c, true)); + } else { + styles = colors.map(c => colorEntryToStyle(c)); + } + + return `linear-gradient(to right, ${styles.join(', ')})`; +} + +function colorEntryToStyle(e: ColorListEntry, includeOffset = false) { + if (Array.isArray(e)) { + if (includeOffset) return `${Color.toStyle(e[0])} ${(100 * e[1]).toFixed(2)}%`; + return Color.toStyle(e[0]); + } + return Color.toStyle(e); +} + +export function parseColorList(input: string, separator: RegExp = /,/): ColorListEntry[] { + const ret: ColorListEntry[] = []; + const trimmed = input.replace(/\s+/g, ''); + let tokenStart = 0; + let bracketLevel = 0; + for (let i = 0, il = trimmed.length; i < il; ++i) { + const c = trimmed[i]; + if (c === '(') { + bracketLevel++; + continue; + } else if (c === ')') { + if (bracketLevel > 0) { + bracketLevel--; + } + continue; + } + + if (bracketLevel > 0) continue; + + if (!separator.test(c)) { + continue; + } + + const color = trimmed.substring(tokenStart, i); + tokenStart = i + 1; + const decoded = decodeColor(color); + if (decoded !== undefined) { + ret.push(decoded); + } + } + + if (tokenStart < trimmed.length) { + const color = trimmed.substring(tokenStart); + const decoded = decodeColor(color); + console.log(color, decoded); + if (decoded !== undefined) { + ret.push(decoded); + } + } + + return ret; +} \ No newline at end of file diff --git a/src/mol-util/file.ts b/src/mol-util/file.ts new file mode 100644 index 000000000..7e84d78d0 --- /dev/null +++ b/src/mol-util/file.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal + */ + +export async function fileToDataUri(file: File): Promise { + const filename = file.name.toLowerCase() || 'file'; + const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].some(ext => filename.endsWith(`.${ext}`)); + + let type = 'application/octet-stream'; + if (isImage) { + const ext = filename.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'jpg': + type = 'image/jpeg'; + break; + default: + type = `image/${ext}`; + break; + } + } + + const bytes = await file.arrayBuffer(); + const reader = new FileReader(); + reader.readAsDataURL(new Blob([bytes], { type })); + const data = await new Promise((resolve, reject) => { + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + }); + + return data; +} \ No newline at end of file