@hyperframes/studio 0.7.106 → 0.7.107
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/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-BLrfwq5o.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BA9yhzfR.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BSBk5srs.js} +1 -1
- package/dist/assets/{index-DerI0ikN.js → index-dF-CmLZu.js} +218 -218
- package/dist/assets/index-zQ4JFwwB.css +1 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +5243 -3933
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.ts +4 -4
- package/src/hooks/useDomEditCommits.test.tsx +103 -21
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +110 -11
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { InlineTextToolbar, swatchBackground } from "./InlineTextToolbar";
|
|
7
|
+
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** An element standing in for one in the preview, and a fake frame around it. */
|
|
16
|
+
function scene(html: string) {
|
|
17
|
+
document.body.innerHTML = `<h1 contenteditable="true">${html}</h1>`;
|
|
18
|
+
const element = document.body.firstElementChild as HTMLElement;
|
|
19
|
+
const iframe = document.createElement("iframe");
|
|
20
|
+
document.body.append(iframe);
|
|
21
|
+
// The composition is drawn scaled, so the toolbar has to map out of it.
|
|
22
|
+
iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 400 }) as DOMRect;
|
|
23
|
+
Object.defineProperty(iframe, "contentWindow", { value: window });
|
|
24
|
+
const session: InlineTextEditSession = {
|
|
25
|
+
element,
|
|
26
|
+
original: html,
|
|
27
|
+
outline: "",
|
|
28
|
+
outlineOffset: "",
|
|
29
|
+
};
|
|
30
|
+
return { element, iframe, session };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function render(session: InlineTextEditSession | null, iframe: HTMLIFrameElement | null) {
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
document.body.append(host);
|
|
36
|
+
const root = createRoot(host);
|
|
37
|
+
act(() => root.render(<InlineTextToolbar session={session} iframe={iframe} />));
|
|
38
|
+
return { host, root, rerender: () => act(() => root.render(<div />)) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function selectAll(element: HTMLElement) {
|
|
42
|
+
const range = document.createRange();
|
|
43
|
+
range.selectNodeContents(element);
|
|
44
|
+
const selection = document.getSelection()!;
|
|
45
|
+
act(() => {
|
|
46
|
+
selection.removeAllRanges();
|
|
47
|
+
selection.addRange(range);
|
|
48
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toolbarIn(host: HTMLElement): HTMLElement | null {
|
|
53
|
+
return host.querySelector<HTMLElement>('[data-inline-text-toolbar="true"]');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderSelected(html = "hello world") {
|
|
57
|
+
const { element, session, iframe } = scene(html);
|
|
58
|
+
const { host } = render(session, iframe);
|
|
59
|
+
selectAll(element);
|
|
60
|
+
return { element, host };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("InlineTextToolbar", () => {
|
|
64
|
+
it("stays out of the way until characters are actually selected", () => {
|
|
65
|
+
const { session, iframe } = scene("hello world");
|
|
66
|
+
const { host } = render(session, iframe);
|
|
67
|
+
|
|
68
|
+
expect(toolbarIn(host)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("appears once a run of characters is selected", () => {
|
|
72
|
+
const { element, session, iframe } = scene("hello world");
|
|
73
|
+
const { host } = render(session, iframe);
|
|
74
|
+
|
|
75
|
+
selectAll(element);
|
|
76
|
+
|
|
77
|
+
expect(toolbarIn(host)?.getAttribute("role")).toBe("toolbar");
|
|
78
|
+
expect(toolbarIn(host)?.getAttribute("aria-label")).toBe("Text formatting");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("goes away when the selection collapses again", () => {
|
|
82
|
+
const { element, session, iframe } = scene("hello world");
|
|
83
|
+
const { host } = render(session, iframe);
|
|
84
|
+
selectAll(element);
|
|
85
|
+
|
|
86
|
+
const selection = document.getSelection()!;
|
|
87
|
+
act(() => {
|
|
88
|
+
selection.collapseToEnd();
|
|
89
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(toolbarIn(host)).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("shows nothing at all when no text is being edited", () => {
|
|
96
|
+
const { iframe } = scene("hello");
|
|
97
|
+
const { host } = render(null, iframe);
|
|
98
|
+
|
|
99
|
+
expect(toolbarIn(host)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps the press, so clicking a control does not collapse the selection", () => {
|
|
103
|
+
const { element, session, iframe } = scene("hello world");
|
|
104
|
+
const { host } = render(session, iframe);
|
|
105
|
+
selectAll(element);
|
|
106
|
+
|
|
107
|
+
const press = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
|
108
|
+
act(() => void toolbarIn(host)!.dispatchEvent(press));
|
|
109
|
+
|
|
110
|
+
expect(press.defaultPrevented).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// It renders inside the canvas overlay, so a press it lets through is read as
|
|
114
|
+
// a click on the composition: the element deselects and the edit commits out
|
|
115
|
+
// from under the button that was just pressed.
|
|
116
|
+
it("keeps its presses away from the canvas underneath", () => {
|
|
117
|
+
const { element, session, iframe } = scene("hello world");
|
|
118
|
+
const seen: string[] = [];
|
|
119
|
+
// A stand-in for the canvas overlay: the toolbar renders inside it, and
|
|
120
|
+
// these are the handlers that would deselect the element.
|
|
121
|
+
const host = document.createElement("div");
|
|
122
|
+
document.body.append(host);
|
|
123
|
+
const root = createRoot(host);
|
|
124
|
+
act(() =>
|
|
125
|
+
root.render(
|
|
126
|
+
<div
|
|
127
|
+
onPointerDown={() => seen.push("pointerdown")}
|
|
128
|
+
onMouseDown={() => seen.push("mousedown")}
|
|
129
|
+
onClick={() => seen.push("click")}
|
|
130
|
+
>
|
|
131
|
+
<InlineTextToolbar session={session} iframe={iframe} />
|
|
132
|
+
</div>,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
selectAll(element);
|
|
136
|
+
|
|
137
|
+
const toolbar = toolbarIn(host)!;
|
|
138
|
+
act(() => {
|
|
139
|
+
for (const type of ["pointerdown", "mousedown", "click"]) {
|
|
140
|
+
toolbar.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(seen).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// A colour input has a user-agent minimum width, so an invisible one pinned
|
|
148
|
+
// only by `inset-0` spills across its neighbours: hovering bold opened the
|
|
149
|
+
// colour picker.
|
|
150
|
+
it("keeps the invisible colour input inside its own swatch", () => {
|
|
151
|
+
const { element, session, iframe } = scene("hello world");
|
|
152
|
+
const { host } = render(session, iframe);
|
|
153
|
+
selectAll(element);
|
|
154
|
+
|
|
155
|
+
const input = host.querySelector<HTMLInputElement>('input[type="color"]')!;
|
|
156
|
+
expect(input.className).toContain("w-full");
|
|
157
|
+
expect(input.className).toContain("h-full");
|
|
158
|
+
expect(input.className).toContain("min-w-0");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("styles the selected characters when a control is used", () => {
|
|
162
|
+
const { element, host } = renderSelected();
|
|
163
|
+
|
|
164
|
+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
165
|
+
|
|
166
|
+
expect(element.innerHTML).toBe('<span style="font-weight: 700">hello world</span>');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ignores a click when the selection disappeared before React hid the toolbar", () => {
|
|
170
|
+
const { element, host } = renderSelected();
|
|
171
|
+
const bold = host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!;
|
|
172
|
+
|
|
173
|
+
expect(() => {
|
|
174
|
+
act(() => {
|
|
175
|
+
document.getSelection()!.removeAllRanges();
|
|
176
|
+
bold.click();
|
|
177
|
+
});
|
|
178
|
+
}).not.toThrow();
|
|
179
|
+
expect(element.innerHTML).toBe("hello world");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reads back the styling it applied, so the control shows the truth", () => {
|
|
183
|
+
const { element, session, iframe } = scene('<span style="font-style: italic">words</span>');
|
|
184
|
+
const { host } = render(session, iframe);
|
|
185
|
+
|
|
186
|
+
selectAll(element);
|
|
187
|
+
|
|
188
|
+
expect(host.querySelector('[aria-label="Italic"]')?.getAttribute("aria-pressed")).toBe("true");
|
|
189
|
+
expect(host.querySelector('[aria-label="Bold"]')?.getAttribute("aria-pressed")).toBe("false");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("turns a style back off when the control is used again", () => {
|
|
193
|
+
const { element, session, iframe } = scene('<span style="font-weight: 700">words</span>');
|
|
194
|
+
const { host } = render(session, iframe);
|
|
195
|
+
selectAll(element);
|
|
196
|
+
|
|
197
|
+
act(() => host.querySelector<HTMLButtonElement>('[aria-label="Bold"]')!.click());
|
|
198
|
+
|
|
199
|
+
expect(element.innerHTML).toBe("words");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("places itself over the selection, mapped out of the scaled composition", () => {
|
|
203
|
+
const { element, session, iframe } = scene("hello world");
|
|
204
|
+
const { host } = render(session, iframe);
|
|
205
|
+
const range = document.createRange();
|
|
206
|
+
range.selectNodeContents(element);
|
|
207
|
+
range.getBoundingClientRect = () =>
|
|
208
|
+
({ left: 20, top: 40, width: 100, height: 10 }) as unknown as DOMRect;
|
|
209
|
+
const selection = document.getSelection()!;
|
|
210
|
+
act(() => {
|
|
211
|
+
selection.removeAllRanges();
|
|
212
|
+
selection.addRange(range);
|
|
213
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const toolbar = toolbarIn(host)!;
|
|
217
|
+
// Frame at 100,50; scale 400/innerWidth; centre of the range, above it.
|
|
218
|
+
const scale = 400 / window.innerWidth;
|
|
219
|
+
expect(toolbar.style.left).toBe(`${100 + (20 + 50) * scale}px`);
|
|
220
|
+
expect(toolbar.style.top).toBe(`${50 + 40 * scale - 10}px`);
|
|
221
|
+
});
|
|
222
|
+
it("moves below a selection that leaves no room above the viewport", () => {
|
|
223
|
+
const { element, session, iframe } = scene("hello world");
|
|
224
|
+
iframe.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400 }) as DOMRect;
|
|
225
|
+
const { host } = render(session, iframe);
|
|
226
|
+
const range = document.createRange();
|
|
227
|
+
range.selectNodeContents(element);
|
|
228
|
+
range.getBoundingClientRect = () =>
|
|
229
|
+
({ left: 20, top: 0, width: 100, height: 10 }) as unknown as DOMRect;
|
|
230
|
+
const selection = document.getSelection()!;
|
|
231
|
+
act(() => {
|
|
232
|
+
selection.removeAllRanges();
|
|
233
|
+
selection.addRange(range);
|
|
234
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const toolbar = toolbarIn(host)!;
|
|
238
|
+
const scale = 400 / window.innerWidth;
|
|
239
|
+
expect(toolbar.style.top).toBe(`${10 * scale + 10}px`);
|
|
240
|
+
expect(toolbar.style.transform).toBe("translate(-50%, 0)");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("shows the selection's colours in the swatch when they differ", () => {
|
|
244
|
+
const { element, session, iframe } = scene(
|
|
245
|
+
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
|
|
246
|
+
);
|
|
247
|
+
const { host } = render(session, iframe);
|
|
248
|
+
|
|
249
|
+
selectAll(element);
|
|
250
|
+
|
|
251
|
+
const swatch = toolbarIn(host)!.querySelector<HTMLElement>("span[aria-hidden]")!;
|
|
252
|
+
expect(swatch.style.backgroundImage).toBe("linear-gradient(135deg, red 0.00%, lime 100.00%)");
|
|
253
|
+
// Without this the gradient repeats under the border, painting the end
|
|
254
|
+
// colour along the leading edge and the start colour along the trailing one.
|
|
255
|
+
expect(swatch.style.backgroundOrigin).toBe("border-box");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("shows a plain swatch when the whole selection is one colour", () => {
|
|
259
|
+
const { element, session, iframe } = scene('<span style="color: red">Hello world</span>');
|
|
260
|
+
const { host } = render(session, iframe);
|
|
261
|
+
|
|
262
|
+
selectAll(element);
|
|
263
|
+
|
|
264
|
+
const swatch = toolbarIn(host)!.querySelector<HTMLElement>("span[aria-hidden]")!;
|
|
265
|
+
expect(swatch.style.backgroundColor).toBe("red");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("opens the colour input on a shorthand hex selection", () => {
|
|
269
|
+
const { element, session, iframe } = scene('<span style="color: #f00">Hello world</span>');
|
|
270
|
+
const { host } = render(session, iframe);
|
|
271
|
+
|
|
272
|
+
selectAll(element);
|
|
273
|
+
|
|
274
|
+
expect(host.querySelector<HTMLInputElement>('input[type="color"]')?.value).toBe("#ff0000");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("swatchBackground", () => {
|
|
279
|
+
it("sweeps through each distinct colour, evenly spaced", () => {
|
|
280
|
+
expect(swatchBackground(["red", "lime"], undefined)).toBe(
|
|
281
|
+
"linear-gradient(135deg, red 0.00%, lime 100.00%)",
|
|
282
|
+
);
|
|
283
|
+
expect(swatchBackground(["red", "lime", "cyan"], undefined)).toBe(
|
|
284
|
+
"linear-gradient(135deg, red 0.00%, lime 50.00%, cyan 100.00%)",
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("stays a plain swatch when the selection is one colour", () => {
|
|
289
|
+
expect(swatchBackground(["red"], "red")).toBe("red");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("falls back to the agreed colour when the characters carry none", () => {
|
|
293
|
+
expect(swatchBackground([], "rgb(1, 2, 3)")).toBe("rgb(1, 2, 3)");
|
|
294
|
+
expect(swatchBackground([], undefined)).toBe("#ffffff");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { applyInlineStyle } from "./inlineTextStyleRange";
|
|
3
|
+
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
|
|
4
|
+
import { parseCssColor, toHexColor } from "./colorValue";
|
|
5
|
+
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The controls for styling the characters selected inside an open text edit.
|
|
9
|
+
*
|
|
10
|
+
* It lives in Studio's document rather than the composition's, positioned over
|
|
11
|
+
* the selection: putting it in the preview would mean injecting Studio's chrome
|
|
12
|
+
* into the user's composition, where it would be captured by a render and
|
|
13
|
+
* inherit the composition's own styling.
|
|
14
|
+
*
|
|
15
|
+
* `position: fixed` and viewport coordinates, so it does not have to know which
|
|
16
|
+
* of the canvas' several nested coordinate systems it was mounted into.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const READ_PROPERTIES = ["color", "font-weight", "font-style", "text-decoration-line"];
|
|
20
|
+
|
|
21
|
+
/** Enough above the text to clear it, without leaving the element behind. */
|
|
22
|
+
const GAP_PX = 10;
|
|
23
|
+
/** h-6 controls + p-1 + the border. Used only to keep an above-toolbar onscreen. */
|
|
24
|
+
const TOOLBAR_HEIGHT_PX = 34;
|
|
25
|
+
const DEFAULT_COLOR = "#ffffff";
|
|
26
|
+
|
|
27
|
+
interface ToolbarPlacement {
|
|
28
|
+
left: number;
|
|
29
|
+
top: number;
|
|
30
|
+
placeBelow: boolean;
|
|
31
|
+
styles: Record<string, string>;
|
|
32
|
+
colours: string[];
|
|
33
|
+
pickerColour: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function InlineTextToolbar({
|
|
37
|
+
session,
|
|
38
|
+
iframe,
|
|
39
|
+
}: {
|
|
40
|
+
session: InlineTextEditSession | null;
|
|
41
|
+
iframe: HTMLIFrameElement | null;
|
|
42
|
+
}) {
|
|
43
|
+
const [placement, setPlacement] = useState<ToolbarPlacement | null>(null);
|
|
44
|
+
|
|
45
|
+
const refresh = useCallback(() => {
|
|
46
|
+
setPlacement(session && iframe ? placeOverSelection(session.element, iframe) : null);
|
|
47
|
+
}, [session, iframe]);
|
|
48
|
+
|
|
49
|
+
// The selection lives in the preview's document, so the event does too.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const doc = session?.element.ownerDocument;
|
|
52
|
+
if (!doc) {
|
|
53
|
+
setPlacement(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
doc.addEventListener("selectionchange", refresh);
|
|
57
|
+
return () => doc.removeEventListener("selectionchange", refresh);
|
|
58
|
+
}, [session, refresh]);
|
|
59
|
+
|
|
60
|
+
const apply = useCallback(
|
|
61
|
+
(delta: Record<string, string | null>) => {
|
|
62
|
+
const doc = session?.element.ownerDocument;
|
|
63
|
+
const selection = doc?.defaultView?.getSelection();
|
|
64
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
65
|
+
const range = selection.getRangeAt(0);
|
|
66
|
+
applyInlineStyle(range, delta);
|
|
67
|
+
refresh();
|
|
68
|
+
},
|
|
69
|
+
[session, refresh],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (!placement) return null;
|
|
73
|
+
const styles = placement.styles;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
data-inline-text-toolbar="true"
|
|
78
|
+
role="toolbar"
|
|
79
|
+
aria-label="Text formatting"
|
|
80
|
+
className="pointer-events-auto fixed z-[200] flex items-center gap-1 rounded-lg border border-white/10 bg-[#15171c] p-1 shadow-[0_8px_24px_rgba(0,0,0,0.55)]"
|
|
81
|
+
style={{
|
|
82
|
+
left: placement.left,
|
|
83
|
+
top: placement.top,
|
|
84
|
+
transform: `translate(-50%, ${placement.placeBelow ? "0" : "-100%"})`,
|
|
85
|
+
}}
|
|
86
|
+
// Two different things have to be stopped here, and missing either one
|
|
87
|
+
// loses the edit the toolbar exists to act on.
|
|
88
|
+
//
|
|
89
|
+
// The default, because a press anywhere in Studio moves the focus, and
|
|
90
|
+
// moving it out of the text collapses the selection being styled.
|
|
91
|
+
//
|
|
92
|
+
// The propagation, because this renders inside the canvas overlay: a
|
|
93
|
+
// press that reaches the canvas is read as a click on the composition,
|
|
94
|
+
// which deselects the element and commits the edit out from under the
|
|
95
|
+
// button that was just pressed.
|
|
96
|
+
onPointerDown={swallow}
|
|
97
|
+
onMouseDown={swallow}
|
|
98
|
+
onClick={(event) => event.stopPropagation()}
|
|
99
|
+
>
|
|
100
|
+
<label
|
|
101
|
+
className="group relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-white/10"
|
|
102
|
+
title="Text colour"
|
|
103
|
+
>
|
|
104
|
+
<span
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
className="h-4 w-4 rounded-full border-2 border-white/25 transition-transform duration-150 group-hover:scale-110 group-active:scale-95"
|
|
107
|
+
// `background` maps a gradient to the PADDING box and then repeats it
|
|
108
|
+
// to fill the border box, so the 1px border shows the strip either
|
|
109
|
+
// side of the tile: the end colour on the left, the start colour on
|
|
110
|
+
// the right. A red-to-green swatch grew a green edge and a red one.
|
|
111
|
+
// Set after the shorthand, which resets it.
|
|
112
|
+
style={{
|
|
113
|
+
background: swatchBackground(placement.colours, styles.color),
|
|
114
|
+
backgroundOrigin: "border-box",
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
{/* `inset-0` is not enough on its own: a colour input carries a
|
|
118
|
+
user-agent minimum width, which wins over the right edge and lets
|
|
119
|
+
the invisible input spill across the buttons beside it. Hovering
|
|
120
|
+
bold then opened the colour picker. The size is pinned instead. */}
|
|
121
|
+
<input
|
|
122
|
+
type="color"
|
|
123
|
+
aria-label="Text colour"
|
|
124
|
+
className="absolute inset-0 h-full w-full min-w-0 cursor-pointer opacity-0"
|
|
125
|
+
value={placement.pickerColour}
|
|
126
|
+
onChange={(event) => apply({ color: event.target.value })}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
129
|
+
<ToolbarToggle
|
|
130
|
+
label="Bold"
|
|
131
|
+
glyph="B"
|
|
132
|
+
bold
|
|
133
|
+
on={isBold(styles["font-weight"])}
|
|
134
|
+
onToggle={(on) => apply({ "font-weight": on ? "700" : null })}
|
|
135
|
+
/>
|
|
136
|
+
<ToolbarToggle
|
|
137
|
+
label="Italic"
|
|
138
|
+
glyph="I"
|
|
139
|
+
italic
|
|
140
|
+
on={styles["font-style"] === "italic"}
|
|
141
|
+
onToggle={(on) => apply({ "font-style": on ? "italic" : null })}
|
|
142
|
+
/>
|
|
143
|
+
<ToolbarToggle
|
|
144
|
+
label="Underline"
|
|
145
|
+
glyph="U"
|
|
146
|
+
underline
|
|
147
|
+
on={styles["text-decoration-line"] === "underline"}
|
|
148
|
+
onToggle={(on) => apply({ "text-decoration-line": on ? "underline" : null })}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The selection's colours as one swatch: a diagonal sweep through each distinct
|
|
156
|
+
* colour, evenly spaced. A selection with one colour is a plain swatch.
|
|
157
|
+
*
|
|
158
|
+
* Distinct rather than weighted, and evenly spaced rather than proportional,
|
|
159
|
+
* matching the mixed-colour swatch in the design tool this sits alongside. The
|
|
160
|
+
* swatch answers "which colours are in here", and at 16px a colour used by one
|
|
161
|
+
* character has to be as visible as one used by thirty or it may as well not be
|
|
162
|
+
* drawn.
|
|
163
|
+
*/
|
|
164
|
+
export function swatchBackground(
|
|
165
|
+
distinctColours: readonly string[],
|
|
166
|
+
agreed: string | undefined,
|
|
167
|
+
): string {
|
|
168
|
+
if (distinctColours.length === 0) return agreed || DEFAULT_COLOR;
|
|
169
|
+
if (distinctColours.length === 1) return distinctColours[0]!;
|
|
170
|
+
const stops = distinctColours.map(
|
|
171
|
+
(colour, index) => `${colour} ${((index / (distinctColours.length - 1)) * 100).toFixed(2)}%`,
|
|
172
|
+
);
|
|
173
|
+
return `linear-gradient(135deg, ${stops.join(", ")})`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function swallow(event: { preventDefault: () => void; stopPropagation: () => void }): void {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
event.stopPropagation();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ToolbarToggle({
|
|
182
|
+
label,
|
|
183
|
+
glyph,
|
|
184
|
+
on,
|
|
185
|
+
onToggle,
|
|
186
|
+
bold,
|
|
187
|
+
italic,
|
|
188
|
+
underline,
|
|
189
|
+
}: {
|
|
190
|
+
label: string;
|
|
191
|
+
glyph: string;
|
|
192
|
+
on: boolean;
|
|
193
|
+
onToggle: (on: boolean) => void;
|
|
194
|
+
bold?: boolean;
|
|
195
|
+
italic?: boolean;
|
|
196
|
+
underline?: boolean;
|
|
197
|
+
}) {
|
|
198
|
+
return (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
title={label}
|
|
202
|
+
aria-label={label}
|
|
203
|
+
aria-pressed={on}
|
|
204
|
+
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs ${
|
|
205
|
+
on ? "bg-studio-accent/20 text-studio-accent" : "text-white/70 hover:bg-white/10"
|
|
206
|
+
}`}
|
|
207
|
+
style={{
|
|
208
|
+
fontWeight: bold ? 700 : 400,
|
|
209
|
+
fontStyle: italic ? "italic" : "normal",
|
|
210
|
+
textDecoration: underline ? "underline" : "none",
|
|
211
|
+
}}
|
|
212
|
+
onClick={() => onToggle(!on)}
|
|
213
|
+
>
|
|
214
|
+
{glyph}
|
|
215
|
+
</button>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Where the selection is on screen, or null when there is nothing selected. */
|
|
220
|
+
function placeOverSelection(
|
|
221
|
+
element: HTMLElement,
|
|
222
|
+
iframe: HTMLIFrameElement,
|
|
223
|
+
): ToolbarPlacement | null {
|
|
224
|
+
const doc = element.ownerDocument;
|
|
225
|
+
const view = doc.defaultView;
|
|
226
|
+
const selection = view?.getSelection();
|
|
227
|
+
if (!view || !selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
228
|
+
|
|
229
|
+
const range = selection.getRangeAt(0);
|
|
230
|
+
if (!element.contains(range.commonAncestorContainer)) return null;
|
|
231
|
+
const rect = range.getBoundingClientRect();
|
|
232
|
+
|
|
233
|
+
// The composition is drawn scaled into the iframe's box, so a point inside it
|
|
234
|
+
// is that scale away from a point on Studio's screen. This is the inverse of
|
|
235
|
+
// the mapping the canvas uses to turn a press into a caret position.
|
|
236
|
+
const box = iframe.getBoundingClientRect();
|
|
237
|
+
const scale = view.innerWidth ? box.width / view.innerWidth : 1;
|
|
238
|
+
const above = box.top + rect.top * scale - GAP_PX;
|
|
239
|
+
const placeBelow = above < TOOLBAR_HEIGHT_PX;
|
|
240
|
+
|
|
241
|
+
const styles = readInlineStyle(range, READ_PROPERTIES);
|
|
242
|
+
const colours = readInlineStyleSpread(range, "color");
|
|
243
|
+
return {
|
|
244
|
+
left: box.left + (rect.left + rect.width / 2) * scale,
|
|
245
|
+
top: placeBelow ? box.top + (rect.top + rect.height) * scale + GAP_PX : above,
|
|
246
|
+
placeBelow,
|
|
247
|
+
styles,
|
|
248
|
+
colours,
|
|
249
|
+
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isBold(weight: string | undefined): boolean {
|
|
254
|
+
if (!weight) return false;
|
|
255
|
+
if (weight === "bold" || weight === "bolder") return true;
|
|
256
|
+
return Number.parseInt(weight, 10) >= 600;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
|
|
260
|
+
function toPickerColour(value: string | undefined, doc: Document): string {
|
|
261
|
+
if (!value) return DEFAULT_COLOR;
|
|
262
|
+
const parsed = parseCssColor(value);
|
|
263
|
+
if (parsed) return toHexColor(parsed);
|
|
264
|
+
|
|
265
|
+
// Canvas delegates the full CSS colour grammar to the browser, including
|
|
266
|
+
// named colours that the small serialisation parser intentionally omits.
|
|
267
|
+
// DOM-only test environments can lack a canvas implementation, in which
|
|
268
|
+
// case the picker degrades to its explicit default while the swatch remains
|
|
269
|
+
// truthful because CSS still paints the original value.
|
|
270
|
+
try {
|
|
271
|
+
const context = doc.createElement("canvas").getContext("2d");
|
|
272
|
+
if (!context) return DEFAULT_COLOR;
|
|
273
|
+
context.fillStyle = DEFAULT_COLOR;
|
|
274
|
+
context.fillStyle = value;
|
|
275
|
+
const normalised =
|
|
276
|
+
typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
|
|
277
|
+
return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
|
|
278
|
+
} catch {
|
|
279
|
+
return DEFAULT_COLOR;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -143,3 +143,41 @@ export function OffCanvasIndicators({
|
|
|
143
143
|
</>
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The dashed outlines around a selected element's children.
|
|
149
|
+
*
|
|
150
|
+
* Extracted from the canvas overlay, which is at its 600-line limit, and it
|
|
151
|
+
* sits here rather than in its own file because it is the same kind of thing:
|
|
152
|
+
* a passive, non-interactive mark the overlay draws over the composition.
|
|
153
|
+
*/
|
|
154
|
+
export function ChildRectOutlines({
|
|
155
|
+
rects,
|
|
156
|
+
}: {
|
|
157
|
+
rects: ReadonlyArray<{
|
|
158
|
+
left: number;
|
|
159
|
+
top: number;
|
|
160
|
+
width: number;
|
|
161
|
+
height: number;
|
|
162
|
+
angle?: number;
|
|
163
|
+
}>;
|
|
164
|
+
}) {
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
{rects.map((rect, index) => (
|
|
168
|
+
<div
|
|
169
|
+
key={index}
|
|
170
|
+
className="pointer-events-none absolute border border-dashed border-white/20 rounded-sm"
|
|
171
|
+
style={{
|
|
172
|
+
left: rect.left,
|
|
173
|
+
top: rect.top,
|
|
174
|
+
width: rect.width,
|
|
175
|
+
height: rect.height,
|
|
176
|
+
// A child outline is drawn ON the child, so it rotates with it.
|
|
177
|
+
transform: rect.angle ? `rotate(${rect.angle}deg)` : undefined,
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
}
|