react-cheminfo 0.77.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/host/core/connectHost.js +1 -1
- package/lib/host/core/connectHost.js.map +1 -1
- package/lib/host/core/connectTool.d.ts +103 -0
- package/lib/host/core/connectTool.d.ts.map +1 -0
- package/lib/host/core/connectTool.js +169 -0
- package/lib/host/core/connectTool.js.map +1 -0
- package/lib/host/core/hostOutputs.d.ts +16 -3
- package/lib/host/core/hostOutputs.d.ts.map +1 -1
- package/lib/host/core/hostOutputs.js +38 -4
- package/lib/host/core/hostOutputs.js.map +1 -1
- package/lib/host/core/index.d.ts +8 -2
- package/lib/host/core/index.d.ts.map +1 -1
- package/lib/host/core/index.js +4 -1
- package/lib/host/core/index.js.map +1 -1
- package/lib/host/core/kinds.d.ts +31 -3
- package/lib/host/core/kinds.d.ts.map +1 -1
- package/lib/host/core/kinds.js +35 -4
- package/lib/host/core/kinds.js.map +1 -1
- package/lib/host/core/messageValues.d.ts +29 -0
- package/lib/host/core/messageValues.d.ts.map +1 -0
- package/lib/host/core/messageValues.js +57 -0
- package/lib/host/core/messageValues.js.map +1 -0
- package/lib/host/core/protocol.d.ts +2 -5
- package/lib/host/core/protocol.d.ts.map +1 -1
- package/lib/host/core/protocol.js +3 -37
- package/lib/host/core/protocol.js.map +1 -1
- package/lib/host/core/resultKinds.d.ts +74 -0
- package/lib/host/core/resultKinds.d.ts.map +1 -0
- package/lib/host/core/resultKinds.js +40 -0
- package/lib/host/core/resultKinds.js.map +1 -0
- package/lib/host/core/toolMessages.d.ts +24 -0
- package/lib/host/core/toolMessages.d.ts.map +1 -0
- package/lib/host/core/toolMessages.js +136 -0
- package/lib/host/core/toolMessages.js.map +1 -0
- package/lib/host/ui/index.d.ts +2 -0
- package/lib/host/ui/index.d.ts.map +1 -1
- package/lib/host/ui/index.js +1 -0
- package/lib/host/ui/index.js.map +1 -1
- package/lib/host/ui/useFramedTool.d.ts +74 -0
- package/lib/host/ui/useFramedTool.d.ts.map +1 -0
- package/lib/host/ui/useFramedTool.js +125 -0
- package/lib/host/ui/useFramedTool.js.map +1 -0
- package/lib/molecule3d/ui/MoleculeCanvas3D.d.ts.map +1 -1
- package/lib/molecule3d/ui/MoleculeCanvas3D.js +10 -2
- package/lib/molecule3d/ui/MoleculeCanvas3D.js.map +1 -1
- package/lib/molecule3d/ui/exportMoleculeImage.d.ts +10 -0
- package/lib/molecule3d/ui/exportMoleculeImage.d.ts.map +1 -1
- package/lib/molecule3d/ui/exportMoleculeImage.js +17 -0
- package/lib/molecule3d/ui/exportMoleculeImage.js.map +1 -1
- package/lib/molecule3d/ui/index.d.ts +1 -1
- package/lib/molecule3d/ui/index.d.ts.map +1 -1
- package/lib/molecule3d/ui/moleculeViewer3DProps.d.ts +23 -0
- package/lib/molecule3d/ui/moleculeViewer3DProps.d.ts.map +1 -1
- package/lib/molecule3d/ui/useImageExport.d.ts +3 -0
- package/lib/molecule3d/ui/useImageExport.d.ts.map +1 -1
- package/lib/molecule3d/ui/useImageExport.js +21 -3
- package/lib/molecule3d/ui/useImageExport.js.map +1 -1
- package/package.json +1 -1
- package/src/host/core/connectHost.ts +1 -1
- package/src/host/core/connectTool.ts +285 -0
- package/src/host/core/hostOutputs.ts +47 -5
- package/src/host/core/index.ts +20 -1
- package/src/host/core/kinds.ts +53 -8
- package/src/host/core/messageValues.ts +58 -0
- package/src/host/core/protocol.ts +5 -37
- package/src/host/core/resultKinds.ts +65 -0
- package/src/host/core/toolMessages.ts +160 -0
- package/src/host/ui/index.ts +6 -0
- package/src/host/ui/useFramedTool.ts +213 -0
- package/src/molecule3d/ui/MoleculeCanvas3D.tsx +10 -1
- package/src/molecule3d/ui/exportMoleculeImage.ts +22 -0
- package/src/molecule3d/ui/index.ts +4 -1
- package/src/molecule3d/ui/moleculeViewer3DProps.ts +24 -0
- package/src/molecule3d/ui/useImageExport.ts +28 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds a tool hands back to the page that frames it: a picture of what it
|
|
3
|
+
* shows, a name it worked out, a table of values it predicted.
|
|
4
|
+
*
|
|
5
|
+
* A host checks what comes back as a tool checks what it is sent: against the
|
|
6
|
+
* kind it asked for, stripped of the fields that kind does not name.
|
|
7
|
+
* `chem.name` is a category — a tool names its preferred IUPAC name
|
|
8
|
+
* `chem.name.iupac` — so a host asking for any name reads every one of them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `image.svg`: a picture, as an SVG document. */
|
|
12
|
+
export interface SvgImageValue {
|
|
13
|
+
/** The SVG document, as text. */
|
|
14
|
+
svg: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `chem.name`: a name a structure goes by. */
|
|
18
|
+
export interface NameValue {
|
|
19
|
+
/** The name, as written. */
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One cell of a `data.table`: a number, a text, or `null` where there is none. */
|
|
24
|
+
export type TableCell = string | number | null;
|
|
25
|
+
|
|
26
|
+
/** `data.table`: values laid out as a table, headings first. */
|
|
27
|
+
export interface TableValue {
|
|
28
|
+
/** The heading of each column, in order. */
|
|
29
|
+
columns: string[];
|
|
30
|
+
/** The rows, each holding one cell per column. */
|
|
31
|
+
rows: TableCell[][];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The schema of `image.svg`. */
|
|
35
|
+
export const SVG_IMAGE_SCHEMA = {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: { svg: { type: 'string' } },
|
|
38
|
+
required: ['svg'],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** The schema of `chem.name`. */
|
|
43
|
+
export const NAME_SCHEMA = {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: { name: { type: 'string' } },
|
|
46
|
+
required: ['name'],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/** The schema of `data.table`. */
|
|
51
|
+
export const TABLE_SCHEMA = {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
columns: { type: 'array', items: { type: 'string' } },
|
|
55
|
+
rows: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: ['string', 'number', 'null'] },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ['columns', 'rows'],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
} as const;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a framed tool sends its host, read as the untrusted input it is: the
|
|
3
|
+
* `hello` it posts to the window, and on the port the `result` of each request
|
|
4
|
+
* and the `output` it sends when a value the host takes back changes.
|
|
5
|
+
*
|
|
6
|
+
* Anything that is not well formed is `null` and is ignored, as a tool ignores
|
|
7
|
+
* a request it does not know: a newer tool talking, not an error.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
MAX_LABEL_LENGTH,
|
|
12
|
+
isRecord,
|
|
13
|
+
readItems,
|
|
14
|
+
readStrings,
|
|
15
|
+
} from './messageValues.ts';
|
|
16
|
+
import type {
|
|
17
|
+
HelloMessage,
|
|
18
|
+
HostError,
|
|
19
|
+
HostErrorCode,
|
|
20
|
+
OutputMessage,
|
|
21
|
+
ResultMessage,
|
|
22
|
+
ToolInput,
|
|
23
|
+
ToolOutput,
|
|
24
|
+
UsedItem,
|
|
25
|
+
} from './protocol.ts';
|
|
26
|
+
import { HELLO_TYPE, PROTOCOL_VERSION } from './protocol.ts';
|
|
27
|
+
|
|
28
|
+
/** What a tool says of itself in `hello`. */
|
|
29
|
+
export type ToolHello = HelloMessage['tool'];
|
|
30
|
+
|
|
31
|
+
/** How long the sentence of an error may be, in characters; the rest is cut. */
|
|
32
|
+
const MAX_MESSAGE_LENGTH = 500;
|
|
33
|
+
|
|
34
|
+
const ERROR_CODES: readonly HostErrorCode[] = [
|
|
35
|
+
'invalid',
|
|
36
|
+
'unreadable',
|
|
37
|
+
'no-input',
|
|
38
|
+
'no-output',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read a tool's `hello`, in this version of the protocol.
|
|
43
|
+
* @param data - What the window delivered.
|
|
44
|
+
* @returns What the tool takes and gives, or `null` for anything else.
|
|
45
|
+
*/
|
|
46
|
+
export function readHello(data: unknown): ToolHello | null {
|
|
47
|
+
if (
|
|
48
|
+
!isRecord(data) ||
|
|
49
|
+
data.type !== HELLO_TYPE ||
|
|
50
|
+
data.version !== PROTOCOL_VERSION
|
|
51
|
+
) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const { tool } = data;
|
|
55
|
+
if (
|
|
56
|
+
!isRecord(tool) ||
|
|
57
|
+
typeof tool.name !== 'string' ||
|
|
58
|
+
typeof tool.origin !== 'string'
|
|
59
|
+
) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
name: tool.name.slice(0, MAX_LABEL_LENGTH),
|
|
64
|
+
origin: tool.origin,
|
|
65
|
+
build: tool.build ?? null,
|
|
66
|
+
inputs: readInputs(tool.inputs),
|
|
67
|
+
outputs: readOutputs(tool.outputs),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read a message a tool sent through the port.
|
|
73
|
+
* @param data - What the port delivered.
|
|
74
|
+
* @returns The `result` or the `output`, or `null` for anything else.
|
|
75
|
+
*/
|
|
76
|
+
export function readToolMessage(
|
|
77
|
+
data: unknown,
|
|
78
|
+
): ResultMessage | OutputMessage | null {
|
|
79
|
+
if (!isRecord(data)) return null;
|
|
80
|
+
if (data.type === 'output') {
|
|
81
|
+
if (typeof data.document !== 'string' || !Array.isArray(data.items)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
type: 'output',
|
|
86
|
+
document: data.document,
|
|
87
|
+
items: readItems(data.items),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (data.type !== 'result') return null;
|
|
91
|
+
const { id, ok, items, used, error } = data;
|
|
92
|
+
if (typeof id !== 'string') return null;
|
|
93
|
+
if (ok === true) {
|
|
94
|
+
return Array.isArray(items)
|
|
95
|
+
? { type: 'result', id, ok: true, items: readItems(items) }
|
|
96
|
+
: { type: 'result', id, ok: true, used: readUsed(used) };
|
|
97
|
+
}
|
|
98
|
+
return { type: 'result', id, ok: false, error: readError(error) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readInputs(value: unknown): ToolInput[] {
|
|
102
|
+
const inputs: ToolInput[] = [];
|
|
103
|
+
if (!Array.isArray(value)) return inputs;
|
|
104
|
+
for (const entry of value) {
|
|
105
|
+
if (!isRecord(entry) || typeof entry.key !== 'string') continue;
|
|
106
|
+
const input: ToolInput = {
|
|
107
|
+
key: entry.key,
|
|
108
|
+
kinds: readStrings(entry.kinds),
|
|
109
|
+
};
|
|
110
|
+
if (entry.many === true) input.many = true;
|
|
111
|
+
if (entry.optional === true) input.optional = true;
|
|
112
|
+
inputs.push(input);
|
|
113
|
+
}
|
|
114
|
+
return inputs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readOutputs(value: unknown): ToolOutput[] {
|
|
118
|
+
const outputs: ToolOutput[] = [];
|
|
119
|
+
if (!Array.isArray(value)) return outputs;
|
|
120
|
+
for (const entry of value) {
|
|
121
|
+
if (
|
|
122
|
+
isRecord(entry) &&
|
|
123
|
+
typeof entry.key === 'string' &&
|
|
124
|
+
typeof entry.kind === 'string'
|
|
125
|
+
) {
|
|
126
|
+
outputs.push({ key: entry.key, kind: entry.kind });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return outputs;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readUsed(value: unknown): UsedItem[] {
|
|
133
|
+
const used: UsedItem[] = [];
|
|
134
|
+
if (!Array.isArray(value)) return used;
|
|
135
|
+
for (const entry of value) {
|
|
136
|
+
if (
|
|
137
|
+
isRecord(entry) &&
|
|
138
|
+
typeof entry.key === 'string' &&
|
|
139
|
+
typeof entry.kind === 'string'
|
|
140
|
+
) {
|
|
141
|
+
used.push({ key: entry.key, kind: entry.kind });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return used;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readError(value: unknown): HostError {
|
|
148
|
+
const error: HostError = {
|
|
149
|
+
code: 'invalid',
|
|
150
|
+
message: 'The tool refused the request and gave no reason.',
|
|
151
|
+
};
|
|
152
|
+
if (!isRecord(value)) return error;
|
|
153
|
+
const code = ERROR_CODES.find((known) => known === value.code);
|
|
154
|
+
if (code !== undefined) error.code = code;
|
|
155
|
+
if (typeof value.message === 'string' && value.message.trim() !== '') {
|
|
156
|
+
error.message = value.message.slice(0, MAX_MESSAGE_LENGTH);
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(value.errors)) error.errors = readStrings(value.errors);
|
|
159
|
+
return error;
|
|
160
|
+
}
|
package/src/host/ui/index.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
FramedTool,
|
|
3
|
+
FramedToolOptions,
|
|
4
|
+
FramedToolStatus,
|
|
5
|
+
} from './useFramedTool.ts';
|
|
6
|
+
export { HELLO_TIMEOUT, useFramedTool } from './useFramedTool.ts';
|
|
1
7
|
export type { HostHandlers } from './useHostConnection.ts';
|
|
2
8
|
export { useHostConnection } from './useHostConnection.ts';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame a tool of the family and talk to it: hand it the document to show,
|
|
3
|
+
* keep what it sends back, and ask it for other values.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
LoadOutcome,
|
|
10
|
+
ToolConnection,
|
|
11
|
+
ToolDocument,
|
|
12
|
+
} from '../core/connectTool.ts';
|
|
13
|
+
import { connectTool } from '../core/connectTool.ts';
|
|
14
|
+
import type { HostItem } from '../core/protocol.ts';
|
|
15
|
+
import type { ToolHello } from '../core/toolMessages.ts';
|
|
16
|
+
|
|
17
|
+
/** What {@link useFramedTool} frames, and what it hands over. */
|
|
18
|
+
export interface FramedToolOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The address the frame opens, `?embed=1` included. Its origin is the only
|
|
21
|
+
* one talked to.
|
|
22
|
+
*/
|
|
23
|
+
url: string;
|
|
24
|
+
/** How the host introduces itself to the tool. */
|
|
25
|
+
host: string;
|
|
26
|
+
/**
|
|
27
|
+
* The document to show, or `null` while there is none. It is handed over
|
|
28
|
+
* whenever its `document` changes, and never twice in a row.
|
|
29
|
+
*/
|
|
30
|
+
document: ToolDocument | null;
|
|
31
|
+
/**
|
|
32
|
+
* How long the tool has to say `hello` before it counts as unavailable, in
|
|
33
|
+
* milliseconds.
|
|
34
|
+
* @default HELLO_TIMEOUT
|
|
35
|
+
*/
|
|
36
|
+
helloTimeout?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether the tool answered: still loading, talking, or silent past the wait. */
|
|
40
|
+
export type FramedToolStatus = 'connecting' | 'connected' | 'unavailable';
|
|
41
|
+
|
|
42
|
+
/** A framed tool, as the host page sees it. */
|
|
43
|
+
export interface FramedTool {
|
|
44
|
+
/**
|
|
45
|
+
* The callback ref of the `<iframe>`, rendered with the hook and given no
|
|
46
|
+
* `src` of its own: the address is set once this page listens, so the
|
|
47
|
+
* tool's `hello` cannot be missed.
|
|
48
|
+
*/
|
|
49
|
+
attachFrame: (element: HTMLIFrameElement | null) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Show the frame on the whole screen, from a click; Esc brings it back.
|
|
52
|
+
* Rejects where the browser will not.
|
|
53
|
+
*/
|
|
54
|
+
fullscreen: () => Promise<void>;
|
|
55
|
+
/** Whether the tool answered. */
|
|
56
|
+
status: FramedToolStatus;
|
|
57
|
+
/** What the tool said it takes and gives, once it has. */
|
|
58
|
+
tool: ToolHello | null;
|
|
59
|
+
/** What became of the document on screen, once the tool answered. */
|
|
60
|
+
outcome: LoadOutcome | null;
|
|
61
|
+
/**
|
|
62
|
+
* The values the document's `accept` names, by output key: those the tool
|
|
63
|
+
* held once it showed the document, then every change it sent.
|
|
64
|
+
*/
|
|
65
|
+
outputs: ReadonlyMap<string, HostItem>;
|
|
66
|
+
/** Ask for the current value of some outputs; rejects with the tool's reason. */
|
|
67
|
+
get: (keys: readonly string[]) => Promise<HostItem[]>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How long a tool has to say `hello`, in milliseconds: a cold start fetches the
|
|
72
|
+
* tool's page, its scripts and its fonts, on a slow line as well.
|
|
73
|
+
*/
|
|
74
|
+
export const HELLO_TIMEOUT = 20_000;
|
|
75
|
+
|
|
76
|
+
const NO_OUTPUTS: ReadonlyMap<string, HostItem> = new Map();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Frame a tool, and keep it showing a document.
|
|
80
|
+
*
|
|
81
|
+
* The frame is opened once and stays: a new document goes to the tool through
|
|
82
|
+
* the protocol, the way a host hands over data, rather than as a new address
|
|
83
|
+
* that would reload the tool and lose what its visitor did in it. A tool that
|
|
84
|
+
* reloads all the same is handed the document again.
|
|
85
|
+
* @param options - See {@link FramedToolOptions}.
|
|
86
|
+
* @returns See {@link FramedTool}.
|
|
87
|
+
*/
|
|
88
|
+
export function useFramedTool(options: FramedToolOptions): FramedTool {
|
|
89
|
+
const { url, host, document, helloTimeout = HELLO_TIMEOUT } = options;
|
|
90
|
+
const frame = useRef<HTMLIFrameElement | null>(null);
|
|
91
|
+
const attachFrame = useCallback((element: HTMLIFrameElement | null) => {
|
|
92
|
+
frame.current = element;
|
|
93
|
+
}, []);
|
|
94
|
+
const connection = useRef<ToolConnection | null>(null);
|
|
95
|
+
const latest = useRef(document);
|
|
96
|
+
const [link, setLink] = useState<Link | null>(null);
|
|
97
|
+
const [answer, setAnswer] = useState<Answer | null>(null);
|
|
98
|
+
const [held, setHeld] = useState<Held | null>(null);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
latest.current = document;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const element = frame.current;
|
|
106
|
+
if (element === null) return;
|
|
107
|
+
let greeted = false;
|
|
108
|
+
const opened = connectTool({
|
|
109
|
+
frame: () => element.contentWindow,
|
|
110
|
+
origin: new URL(url, globalThis.location.href).origin,
|
|
111
|
+
host: { name: host },
|
|
112
|
+
listen: (listener) => {
|
|
113
|
+
globalThis.addEventListener('message', listener);
|
|
114
|
+
return () => {
|
|
115
|
+
globalThis.removeEventListener('message', listener);
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
onHello: (tool) => {
|
|
119
|
+
greeted = true;
|
|
120
|
+
setLink({ url, status: 'connected', tool });
|
|
121
|
+
},
|
|
122
|
+
onOutput: (message) => {
|
|
123
|
+
setHeld((previous) =>
|
|
124
|
+
hold(previous, `${url}\n${message.document}`, message.items),
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
connection.current = opened;
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
if (!greeted) setLink({ url, status: 'unavailable', tool: null });
|
|
131
|
+
}, helloTimeout);
|
|
132
|
+
// Set once the listener exists, and again when the effect runs anew, so
|
|
133
|
+
// the tool greets this listener rather than one already closed.
|
|
134
|
+
element.src = url;
|
|
135
|
+
return () => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
opened.close();
|
|
138
|
+
if (connection.current === opened) connection.current = null;
|
|
139
|
+
};
|
|
140
|
+
}, [url, host, helloTimeout]);
|
|
141
|
+
|
|
142
|
+
const name = document?.document ?? null;
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const opened = connection.current;
|
|
145
|
+
const shown = latest.current;
|
|
146
|
+
if (opened === null || shown === null) return;
|
|
147
|
+
const key = `${url}\n${shown.document}`;
|
|
148
|
+
void opened.load(shown).then(async (outcome) => {
|
|
149
|
+
setAnswer({ key, outcome });
|
|
150
|
+
const accept = shown.accept ?? [];
|
|
151
|
+
if (outcome.status !== 'loaded' || accept.length === 0) return;
|
|
152
|
+
// What the tool held once it showed the document is never sent unasked.
|
|
153
|
+
const items = await opened.get(accept).catch(() => []);
|
|
154
|
+
setHeld((previous) => hold(previous, key, items));
|
|
155
|
+
});
|
|
156
|
+
}, [name, url, host]);
|
|
157
|
+
|
|
158
|
+
const fullscreen = useCallback(async () => {
|
|
159
|
+
const element = frame.current;
|
|
160
|
+
if (element === null) throw new Error('The tool is not open.');
|
|
161
|
+
await element.requestFullscreen();
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
const get = useCallback(async (keys: readonly string[]) => {
|
|
165
|
+
const opened = connection.current;
|
|
166
|
+
if (opened === null) {
|
|
167
|
+
throw new Error('The tool is not open, so it has nothing to give.');
|
|
168
|
+
}
|
|
169
|
+
return opened.get(keys);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const key = name === null ? null : `${url}\n${name}`;
|
|
173
|
+
const current = link?.url === url ? link : null;
|
|
174
|
+
return {
|
|
175
|
+
attachFrame,
|
|
176
|
+
fullscreen,
|
|
177
|
+
status: current?.status ?? 'connecting',
|
|
178
|
+
tool: current?.tool ?? null,
|
|
179
|
+
outcome: answer !== null && answer.key === key ? answer.outcome : null,
|
|
180
|
+
outputs: held !== null && held.key === key ? held.outputs : NO_OUTPUTS,
|
|
181
|
+
get,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface Link {
|
|
186
|
+
url: string;
|
|
187
|
+
status: Exclude<FramedToolStatus, 'connecting'>;
|
|
188
|
+
tool: ToolHello | null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface Answer {
|
|
192
|
+
key: string;
|
|
193
|
+
outcome: LoadOutcome;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface Held {
|
|
197
|
+
key: string;
|
|
198
|
+
outputs: ReadonlyMap<string, HostItem>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hold(
|
|
202
|
+
previous: Held | null,
|
|
203
|
+
key: string,
|
|
204
|
+
items: readonly HostItem[],
|
|
205
|
+
): Held {
|
|
206
|
+
const outputs = new Map(
|
|
207
|
+
previous?.key === key ? previous.outputs : NO_OUTPUTS,
|
|
208
|
+
);
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
if (item.key !== undefined) outputs.set(item.key, item);
|
|
211
|
+
}
|
|
212
|
+
return { key, outputs };
|
|
213
|
+
}
|
|
@@ -64,6 +64,7 @@ export function MoleculeCanvas3D(props: MoleculeCanvas3DProps): ReactElement {
|
|
|
64
64
|
fileName = 'molecule',
|
|
65
65
|
background,
|
|
66
66
|
minHeight = 320,
|
|
67
|
+
onCapture,
|
|
67
68
|
onFailureChange,
|
|
68
69
|
} = props;
|
|
69
70
|
const tools = resolveMolecule3DTools(toolsProp);
|
|
@@ -139,13 +140,21 @@ export function MoleculeCanvas3D(props: MoleculeCanvas3DProps): ReactElement {
|
|
|
139
140
|
camera: props,
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
const { canvasSize, exportImage, copyImage } = useImageExport(
|
|
143
|
+
const { canvasSize, exportImage, copyImage, capture } = useImageExport(
|
|
143
144
|
container,
|
|
144
145
|
viewerRef,
|
|
145
146
|
fileName,
|
|
146
147
|
framesPerSecond,
|
|
147
148
|
);
|
|
148
149
|
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (onCapture === undefined || container === null) return;
|
|
152
|
+
onCapture(capture);
|
|
153
|
+
return () => {
|
|
154
|
+
onCapture(null);
|
|
155
|
+
};
|
|
156
|
+
}, [onCapture, container, capture]);
|
|
157
|
+
|
|
149
158
|
return (
|
|
150
159
|
<Molecule3DStage style={{ ...STAGE_STYLE, minHeight }}>
|
|
151
160
|
{(fullscreen) => (
|
|
@@ -59,6 +59,28 @@ export async function captureMoleculePng(
|
|
|
59
59
|
return new Blob([dataUriBytes(dataUri)], { type: FIGURE_PNG_TYPE });
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Render the scene as an SVG holding the rendered image, as the export writes
|
|
64
|
+
* it.
|
|
65
|
+
* @param viewer - The viewer holding the scene.
|
|
66
|
+
* @param scale - Multiple of the canvas on screen it is rendered at.
|
|
67
|
+
* @param size - Size of the canvas on screen, CSS pixels.
|
|
68
|
+
* @returns The SVG markup.
|
|
69
|
+
* @throws {Error} When the viewer has nothing to render.
|
|
70
|
+
*/
|
|
71
|
+
export async function captureMoleculeSvg(
|
|
72
|
+
viewer: SceneCapture,
|
|
73
|
+
scale: number,
|
|
74
|
+
size: ImageSize,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const pixels = figurePixels(size, scale);
|
|
77
|
+
const dataUri = await viewer.captureImage(pixels);
|
|
78
|
+
if (dataUri === undefined) {
|
|
79
|
+
throw new Error('There is no picture to copy yet.');
|
|
80
|
+
}
|
|
81
|
+
return rasterSvgMarkup(dataUri, pixels);
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
/**
|
|
63
85
|
* Render the scene and hand it to the browser as a file.
|
|
64
86
|
* @param viewer - The viewer holding the scene.
|
|
@@ -25,4 +25,7 @@ export { exportMoleculeImage } from './exportMoleculeImage.ts';
|
|
|
25
25
|
export type { ImageExport } from './useImageExport.ts';
|
|
26
26
|
export { useImageExport } from './useImageExport.ts';
|
|
27
27
|
export { MoleculeViewer3D } from './MoleculeViewer3D.tsx';
|
|
28
|
-
export type {
|
|
28
|
+
export type {
|
|
29
|
+
Molecule3DCapture,
|
|
30
|
+
MoleculeViewer3DProps,
|
|
31
|
+
} from './moleculeViewer3DProps.ts';
|
|
@@ -7,6 +7,23 @@ import type { Molecule3DHighlight } from '../core/highlight.ts';
|
|
|
7
7
|
import type { Measurement } from '../core/measurement.ts';
|
|
8
8
|
import type { Molecule3DSettings, Molecule3DTools } from '../core/settings.ts';
|
|
9
9
|
|
|
10
|
+
/** The scene on screen, rendered as a picture on request. */
|
|
11
|
+
export interface Molecule3DCapture {
|
|
12
|
+
/**
|
|
13
|
+
* Render the scene as an SVG document holding the rendered image: a molecule
|
|
14
|
+
* drawn by WebGL has no vector form.
|
|
15
|
+
* @param scale - Multiple of the canvas on screen it is rendered at.
|
|
16
|
+
* @returns The SVG markup.
|
|
17
|
+
*/
|
|
18
|
+
svg: (scale: number) => Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Render the scene as a PNG.
|
|
21
|
+
* @param scale - Multiple of the canvas on screen it is rendered at.
|
|
22
|
+
* @returns The PNG.
|
|
23
|
+
*/
|
|
24
|
+
png: (scale: number) => Promise<Blob>;
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
/**
|
|
11
28
|
* Props of `MoleculeViewer3D`.
|
|
12
29
|
*
|
|
@@ -129,6 +146,13 @@ export interface MoleculeViewer3DProps {
|
|
|
129
146
|
* @default 'molecule'
|
|
130
147
|
*/
|
|
131
148
|
fileName?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Handed the means to render the scene on screen once the viewer exists, and
|
|
151
|
+
* `null` when it goes — for a page that hands the picture on, to a host
|
|
152
|
+
* framing it. Keep it stable: a new function is handed the means again.
|
|
153
|
+
* @default undefined
|
|
154
|
+
*/
|
|
155
|
+
onCapture?: (capture: Molecule3DCapture | null) => void;
|
|
132
156
|
/**
|
|
133
157
|
* Scene background, as `#rrggbb`.
|
|
134
158
|
* @default '#ffffff'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RefObject } from 'react';
|
|
2
|
-
import { useCallback } from 'react';
|
|
2
|
+
import { useCallback, useMemo } from 'react';
|
|
3
3
|
|
|
4
4
|
import type { FigureNotice } from '../../download/core/copyFigure.ts';
|
|
5
5
|
import { copyFigure } from '../../download/core/copyFigure.ts';
|
|
@@ -10,8 +10,10 @@ import { DEFAULT_FRAMES_PER_SECOND } from '../core/animatedPng.ts';
|
|
|
10
10
|
import type { SceneCapture } from './exportMoleculeImage.ts';
|
|
11
11
|
import {
|
|
12
12
|
captureMoleculePng,
|
|
13
|
+
captureMoleculeSvg,
|
|
13
14
|
exportMoleculeImage,
|
|
14
15
|
} from './exportMoleculeImage.ts';
|
|
16
|
+
import type { Molecule3DCapture } from './moleculeViewer3DProps.ts';
|
|
15
17
|
|
|
16
18
|
/** What {@link useImageExport} hands the export panel. */
|
|
17
19
|
export interface ImageExport {
|
|
@@ -31,6 +33,8 @@ export interface ImageExport {
|
|
|
31
33
|
* from the click itself: Safari only allows a write the click started.
|
|
32
34
|
*/
|
|
33
35
|
copyImage: (scale: number) => Promise<FigureNotice>;
|
|
36
|
+
/** The scene rendered on request, for a page handing the picture on. */
|
|
37
|
+
capture: Molecule3DCapture;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -85,5 +89,27 @@ export function useImageExport(
|
|
|
85
89
|
[canvasSize, viewerRef],
|
|
86
90
|
);
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
const capture = useMemo(
|
|
93
|
+
(): Molecule3DCapture => ({
|
|
94
|
+
svg: async (scale) => {
|
|
95
|
+
const viewer = viewerRef.current;
|
|
96
|
+
const size = canvasSize();
|
|
97
|
+
if (viewer === null || size === null) {
|
|
98
|
+
throw new Error('There is no picture to copy yet.');
|
|
99
|
+
}
|
|
100
|
+
return captureMoleculeSvg(viewer, scale, size);
|
|
101
|
+
},
|
|
102
|
+
png: async (scale) => {
|
|
103
|
+
const viewer = viewerRef.current;
|
|
104
|
+
const size = canvasSize();
|
|
105
|
+
if (viewer === null || size === null) {
|
|
106
|
+
throw new Error('There is no picture to copy yet.');
|
|
107
|
+
}
|
|
108
|
+
return captureMoleculePng(viewer, scale, size);
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
[canvasSize, viewerRef],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return { canvasSize, exportImage, copyImage, capture };
|
|
89
115
|
}
|