@m4l-jweb/bridge 1.1.0 → 1.2.1
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/README.md +1 -0
- package/package.json +1 -1
- package/src/clipboard.ts +174 -0
- package/src/index.ts +38 -7
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ outlet("my_selector", 1, "two");
|
|
|
36
36
|
- **Audio does not go through here.** Under `[jweb~]` the page's Web Audio output is carried on the object's signal outlets, straight into the track. The bridge is a control plane; sound never crosses it as messages.
|
|
37
37
|
- `fetchToFile()` and `saveToFile()` move bytes between the page and disk via Max's `[maxurl]`, so large files never travel through the message bridge either.
|
|
38
38
|
- `tapMessages()` observes every message in both directions - the whole contract of a device, live.
|
|
39
|
+
- `copyPath()` answers "where did my file go". Nothing in Max can open a file manager, and inside `[jweb~]` a clipboard write can be claimed but never confirmed - so the result is `copied` / `manual` / `cancelled`, never a boolean that might be lying.
|
|
39
40
|
|
|
40
41
|
## Requirements
|
|
41
42
|
|
package/package.json
CHANGED
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting a PATH in front of the user, because revealing a folder is impossible.
|
|
3
|
+
*
|
|
4
|
+
* Every device that writes files hits the same question - "where did it go" - and
|
|
5
|
+
* neither the page nor Max can open a file manager. `; max launchbrowser <folder>` is
|
|
6
|
+
* the only door Max offers and it does not open: measured in Live on Windows 11 in
|
|
7
|
+
* both forms, a percent-encoded `file:///C:/...` URL with a WRONG path raised a real
|
|
8
|
+
* shell dialog naming it, while a correct one opened nothing and reported nothing; a
|
|
9
|
+
* native backslash path behaved identically. `[js]` has no shell call and there is no
|
|
10
|
+
* second Max object that reveals a path. See doc/MAX-FACTS.md.
|
|
11
|
+
*
|
|
12
|
+
* So the answer is the clipboard, and the clipboard inside jweb lies:
|
|
13
|
+
* `document.execCommand("copy")` RETURNS TRUE and copies nothing, and the page cannot
|
|
14
|
+
* tell, because `navigator.clipboard.readText()` needs a secure context and a `file://`
|
|
15
|
+
* page does not have one. A copy can be claimed but never confirmed - which produced
|
|
16
|
+
* the worst possible UI, a status line reading "Path copied" next to an empty paste.
|
|
17
|
+
*
|
|
18
|
+
* Hence the rule this module encodes: inside jweb, never trust a claim. Attempt the
|
|
19
|
+
* programmatic copy anyway (it costs nothing and works in some builds), then show a
|
|
20
|
+
* focused, pre-selected field and wait for the browser's own `copy` event, which fires
|
|
21
|
+
* only when a copy really happens. The result is three-way rather than boolean, so a
|
|
22
|
+
* caller cannot accidentally report success it does not have.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read at call time, not imported from ./index: index re-exports this module, and a
|
|
27
|
+
* cycle that only works because one side happens to be evaluated first is not worth
|
|
28
|
+
* the one binding it saves.
|
|
29
|
+
*/
|
|
30
|
+
const inJweb = () => typeof window !== "undefined" && !!(window as { max?: unknown }).max;
|
|
31
|
+
|
|
32
|
+
export type CopyResult =
|
|
33
|
+
/** The programmatic copy was made AND could be trusted (outside jweb). */
|
|
34
|
+
| "copied"
|
|
35
|
+
/** The user pressed Ctrl+C on the field we showed - the browser confirmed it. */
|
|
36
|
+
| "manual"
|
|
37
|
+
/** The field was dismissed without a copy. The caller still shows the path. */
|
|
38
|
+
| "cancelled";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Copy `text`, falling back to the user's own Ctrl+C, and report what really happened.
|
|
42
|
+
*
|
|
43
|
+
* Resolves as soon as the outcome is known: immediately outside jweb, and on the copy
|
|
44
|
+
* event (or dismissal) inside it.
|
|
45
|
+
*/
|
|
46
|
+
export async function copyPath(text: string): Promise<CopyResult> {
|
|
47
|
+
const claimed = await attemptCopy(text);
|
|
48
|
+
// Outside Max (browser dev) the APIs are trustworthy: a secure context, a real
|
|
49
|
+
// clipboard, and a `writeText` that rejects when it fails.
|
|
50
|
+
if (claimed && !inJweb()) return "copied";
|
|
51
|
+
return promptCopy(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The programmatic attempt, both mechanisms. Its `true` is a CLAIM, not a fact. */
|
|
55
|
+
async function attemptCopy(text: string): Promise<boolean> {
|
|
56
|
+
if (execCommandCopy(text)) return true;
|
|
57
|
+
try {
|
|
58
|
+
// NOT `navigator.clipboard?.writeText(...)`: optional chaining on a MISSING API
|
|
59
|
+
// yields undefined, `await undefined` resolves, and this reported success on
|
|
60
|
+
// exactly the page where there is no clipboard API at all.
|
|
61
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return false;
|
|
62
|
+
await navigator.clipboard.writeText(text);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The selection-based copy, synchronous. Must run inside the click's user gesture. */
|
|
70
|
+
function execCommandCopy(text: string): boolean {
|
|
71
|
+
if (typeof document === "undefined") return false;
|
|
72
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
73
|
+
const area = document.createElement("textarea");
|
|
74
|
+
try {
|
|
75
|
+
area.value = text;
|
|
76
|
+
// IN the viewport, not off at -1000px: an element scrolled out of view can fail to
|
|
77
|
+
// take focus, and a selection nothing owns copies nothing. Invisible is enough.
|
|
78
|
+
area.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;padding:0;border:0";
|
|
79
|
+
area.setAttribute("readonly", "");
|
|
80
|
+
document.body.appendChild(area);
|
|
81
|
+
// focus() BEFORE select(): select() alone leaves the selection unowned in CEF.
|
|
82
|
+
area.focus();
|
|
83
|
+
area.select();
|
|
84
|
+
area.setSelectionRange(0, text.length);
|
|
85
|
+
return document.execCommand("copy");
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
} finally {
|
|
89
|
+
area.remove();
|
|
90
|
+
previous?.focus?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PROMPT_ID = "m4l-copy-prompt";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Show the path in a focused, pre-selected field and wait for the user to copy it.
|
|
98
|
+
*
|
|
99
|
+
* Resolves "manual" on the browser's `copy` event - the only confirmation this page can
|
|
100
|
+
* get - and "cancelled" on Escape, on the close button, or on a click outside. It does
|
|
101
|
+
* NOT dismiss on blur: focus can be lost to Max itself, and a field that vanishes when
|
|
102
|
+
* you look away is a field you cannot use.
|
|
103
|
+
*/
|
|
104
|
+
export function promptCopy(text: string): Promise<CopyResult> {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
try {
|
|
107
|
+
if (typeof document === "undefined") {
|
|
108
|
+
resolve("cancelled");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
document.getElementById(PROMPT_ID)?.remove();
|
|
112
|
+
|
|
113
|
+
const box = document.createElement("div");
|
|
114
|
+
box.id = PROMPT_ID;
|
|
115
|
+
box.style.cssText =
|
|
116
|
+
"position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;gap:4px;" +
|
|
117
|
+
"justify-content:center;padding:8px;background:rgba(0,0,0,.92);color:#fff;font:11px system-ui";
|
|
118
|
+
|
|
119
|
+
const label = document.createElement("div");
|
|
120
|
+
label.textContent = "Press Ctrl+C (Cmd+C) to copy this path";
|
|
121
|
+
label.style.cssText = "opacity:.7";
|
|
122
|
+
|
|
123
|
+
const input = document.createElement("input");
|
|
124
|
+
input.readOnly = true;
|
|
125
|
+
input.value = text;
|
|
126
|
+
input.style.cssText =
|
|
127
|
+
"width:100%;background:#111;color:#fff;border:1px solid #444;border-radius:3px;" +
|
|
128
|
+
"padding:3px 4px;font:10px monospace";
|
|
129
|
+
|
|
130
|
+
const close = document.createElement("button");
|
|
131
|
+
close.textContent = "Close";
|
|
132
|
+
close.style.cssText =
|
|
133
|
+
"align-self:flex-end;background:#222;color:#fff;border:1px solid #444;border-radius:3px;" +
|
|
134
|
+
"padding:2px 8px;font:10px system-ui;cursor:pointer";
|
|
135
|
+
|
|
136
|
+
let done = false;
|
|
137
|
+
const finish = (r: CopyResult) => {
|
|
138
|
+
if (done) return;
|
|
139
|
+
done = true;
|
|
140
|
+
box.remove();
|
|
141
|
+
resolve(r);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The one honest confirmation: the browser only fires this when a copy happens.
|
|
145
|
+
input.addEventListener("copy", () => finish("manual"));
|
|
146
|
+
input.addEventListener("keydown", (e) => {
|
|
147
|
+
if (e.key === "Escape") finish("cancelled");
|
|
148
|
+
});
|
|
149
|
+
close.addEventListener("click", () => finish("cancelled"));
|
|
150
|
+
box.addEventListener("mousedown", (e) => {
|
|
151
|
+
if (e.target === box) finish("cancelled");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
box.append(label, input, close);
|
|
155
|
+
document.body.appendChild(box);
|
|
156
|
+
input.focus();
|
|
157
|
+
input.select();
|
|
158
|
+
} catch {
|
|
159
|
+
resolve("cancelled");
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* What to tell the user, per outcome.
|
|
166
|
+
*
|
|
167
|
+
* Here rather than in each device because the wording is the whole point: the status
|
|
168
|
+
* line is the only place any of this is visible, and it must never claim a copy that
|
|
169
|
+
* did not happen - which is the bug this module exists to stop.
|
|
170
|
+
*/
|
|
171
|
+
export function copyMessage(result: CopyResult, path: string): string {
|
|
172
|
+
if (result === "copied" || result === "manual") return `Path copied: ${path}`;
|
|
173
|
+
return `Not copied - the folder is ${path}`;
|
|
174
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -274,16 +274,35 @@ export const PARAM_IN = {
|
|
|
274
274
|
* device cannot rename a parameter there.
|
|
275
275
|
* `unit` takes. It is the unit STYLE, so the readout says "600 Hz" and not "600";
|
|
276
276
|
* anything outside Max's list becomes a custom unit and still prints.
|
|
277
|
-
* `range` takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. That is the
|
|
278
|
-
* a page that goes on normalizing 0..1 will scale a value that is
|
|
279
|
-
* scaled, and the control sticks at its minimum. So the wrapper
|
|
280
|
-
* `param_range_ok` or `param_range_failed` - and the caller must
|
|
281
|
-
* normalizing when it took. `onParamRange()` below is that answer.
|
|
277
|
+
* `range` takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. That is the first
|
|
278
|
+
* trap: a page that goes on normalizing 0..1 will scale a value that is
|
|
279
|
+
* already scaled, and the control sticks at its minimum. So the wrapper
|
|
280
|
+
* ANSWERS - `param_range_ok` or `param_range_failed` - and the caller must
|
|
281
|
+
* stop normalizing when it took. `onParamRange()` below is that answer.
|
|
282
|
+
*
|
|
283
|
+
* AND IT IS NOW OFF BY DEFAULT, because of a second trap that is worse.
|
|
284
|
+
* Measured in Live: a dial whose range was widened at runtime STOPS
|
|
285
|
+
* FOLLOWING ITS AUTOMATION LANE AND ANY RACK MACRO MAPPED TO IT. Both go on
|
|
286
|
+
* driving the parameter in its BUILD-TIME domain - the frozen device is what
|
|
287
|
+
* Live bound them against - so a macro at 0.5 writes 0.5 into a parameter
|
|
288
|
+
* now spanning 200..2000, and the dial sits at the bottom, unmoving, with no
|
|
289
|
+
* error anywhere. A sibling dial left at 0..1 in the same device responds
|
|
290
|
+
* normally, which is what isolates it to the widening.
|
|
291
|
+
*
|
|
292
|
+
* So `range` alone is DESCRIPTIVE: the page keeps the scaling and the
|
|
293
|
+
* parameter stays automatable. `widenRange: true` asks for the old
|
|
294
|
+
* behaviour, and is worth it only where a unit readout on the dial beats
|
|
295
|
+
* automation and macros on it.
|
|
282
296
|
*/
|
|
283
|
-
export function describeParam(
|
|
297
|
+
export function describeParam(
|
|
298
|
+
id: string,
|
|
299
|
+
desc: { name?: string; unit?: string; range?: [number, number]; widenRange?: boolean },
|
|
300
|
+
): void {
|
|
284
301
|
if (desc.name) outlet(PARAM_OUT.param_label, id, desc.name);
|
|
285
302
|
if (desc.unit) outlet(PARAM_OUT.param_unit, id, desc.unit);
|
|
286
|
-
if (desc.
|
|
303
|
+
if (desc.widenRange && desc.range && desc.range[1] > desc.range[0]) {
|
|
304
|
+
outlet(PARAM_OUT.param_range, id, desc.range[0], desc.range[1]);
|
|
305
|
+
}
|
|
287
306
|
}
|
|
288
307
|
|
|
289
308
|
/**
|
|
@@ -733,6 +752,18 @@ export function decodeBase64(s: string): string {
|
|
|
733
752
|
return new TextDecoder().decode(bytes);
|
|
734
753
|
}
|
|
735
754
|
|
|
755
|
+
/* ------------------------------------------------------------------ *
|
|
756
|
+
* Telling the user where a file went
|
|
757
|
+
* ------------------------------------------------------------------ */
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* A device that writes files needs to answer "where did it go", and neither the page
|
|
761
|
+
* nor Max can open a file manager. Re-exported here so `@m4l-jweb/bridge` stays the one
|
|
762
|
+
* import; the reasoning and the failure modes are in ./clipboard.
|
|
763
|
+
*/
|
|
764
|
+
export { copyPath, promptCopy, copyMessage } from "./clipboard";
|
|
765
|
+
export type { CopyResult } from "./clipboard";
|
|
766
|
+
|
|
736
767
|
/**
|
|
737
768
|
* Dev shim. Outside Max there is no window.max, so route messages straight into
|
|
738
769
|
* the bound handlers and let the developer drive the device from the console:
|