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
This commit is contained in:
David Sehnal
2025-07-04 10:29:03 +02:00
committed by GitHub
parent 13b1e5d59c
commit 146e95cb23
19 changed files with 1129 additions and 102 deletions

View File

@@ -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

View File

@@ -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, {

View File

@@ -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
)
```

View File

@@ -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

359
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -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
<div style={{ flexGrow: 1, overflow: 'hidden', overflowY: 'auto', position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<PluginReactContext.Provider value={model.viewer?.model.plugin as any}>
<Markdown skipHtml components={{ a: MarkdownAnchor }}>{state.entry?.description ?? 'Description not available'}</Markdown>
<Markdown>{state.entry?.description ?? 'Description not available'}</Markdown>
</PluginReactContext.Provider>
</div>
</div>

View File

@@ -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) {

View File

@@ -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: () => ({

View File

@@ -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> = S extends Set<infer T> ? 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' */

View File

@@ -0,0 +1,248 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
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<string, string>,
manager: MarkdownExtensionManager
}) => void;
reactRenderFn?: (options: {
args: Record<string, string>,
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<string, (plugin: PluginContext, refs: string[]) => StateObjectCell[]> = {
default: (plugin: PluginContext, refs: string[]) => refs
.map(ref => plugin.state.data.cells.get(ref))
.filter(c => !!c),
};
private uriResolvers: Record<string, (plugin: PluginContext, uri: string) => Promise<string> | string | undefined> = {};
private argsParsers: [name: string, priority: number, parser: (input: string | undefined) => Record<string, string> | 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<string, string> | 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<string, string> | 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> | 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<string, string>, 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<string, string>, 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<string, string>) {
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> | 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<string>();
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<string, string> | 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);
}

View File

@@ -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 <div id='snapinfo' className='msp-snapshot-description-wrapper'>
{e.descriptionFormat === 'plaintext'
&& e.description
|| <Markdown skipHtml components={{ a: MarkdownAnchor }}>{e.description}</Markdown>
|| <Markdown>{e.description}</Markdown>
}
</div>;
}
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 <a href='#' onClick={(e) => {
e.preventDefault();
plugin.managers.snapshot.applyKey(href.substring(1));
}}>{children}</a>;
} else if (href) {
return <a href={href} target='_blank' rel='noopener noreferrer'>{children}</a>;
}
// 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<Lo
{this.state.labels.map((e, i) => {
if (e.indexOf('\n') >= 0) {
return <div className='msp-highlight-markdown-row' key={'' + i}>
<Markdown skipHtml>{e}</Markdown>
<ReactMarkdown skipHtml>{e}</ReactMarkdown>
</div>;
}

View File

@@ -0,0 +1,141 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
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 <div className='msp-markdown'>
<ReactMarkdown
skipHtml
components={{ a: MarkdownAnchor, img: MarkdownImg, ...components }}
remarkPlugins={[remarkGfm]}
>
{children}
</ReactMarkdown>
</div>;
}
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<string>)?.then === 'function') {
return <LazyStaticImg alt={alt} data={data as Promise<string>} />;
} else if (typeof data === 'string' && data) {
return <img src={data} alt={alt} />;
}
}
return <img src={src} alt={alt} />;
}
function LazyStaticImg({ alt, data }: { alt?: string, data: Promise<string> }) {
const [src, setSrc] = useState<string | undefined>(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 <img src={src} alt={alt} />;
}
export const DefaultMarkdownExtensionRenderers: MarkdownExtension[] = [
{
name: 'color-swatch',
reactRenderFn: ({ args }) => {
const color = args['color-swatch'];
if (!color) return null;
return <span style={{ display: 'inline-block', width: '0.75em', height: '0.75em', backgroundColor: color, borderRadius: '25%' }}/>;
}
},
{
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 <span style={{
display: 'inline-block',
minWidth,
height,
background: (discrete ? getColorGradientBanded : getColorGradient)(list),
borderRadius: '2px'
}} />;
}
}
];
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 <a href='#'
onClick={(e) => {
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}
</a>;
} else if (href[0] === '#') {
warnMissingPlugin(plugin);
return <a href='#' onClick={(e) => {
e.preventDefault();
plugin?.managers.snapshot.applyKey(href.substring(1));
}}>{children}</a>;
} else if (href) {
return <a href={href} target='_blank' rel='noopener noreferrer'>{children}</a>;
}
return children;
}
function warnMissingPlugin(plugin: PluginContext | undefined) {
if (plugin) return;
console.warn('Markdown component requires a PluginReactContext to be set.');
}

View File

@@ -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<PD.Color> {
}
}
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 {

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;

144
src/mol-util/color/utils.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
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;
}

33
src/mol-util/file.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
export async function fileToDataUri(file: File): Promise<string> {
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<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
});
return data;
}