@hyperframes/studio 0.7.106 → 0.7.108
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-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -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 +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- 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/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- 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/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -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/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -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 +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- 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/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- 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-DerI0ikN.js +0 -428
- 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,743 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { applyInlineStyle } from "./inlineTextStyleRange";
|
|
5
|
+
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
document.body.innerHTML = "";
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function mount(html: string): HTMLElement {
|
|
13
|
+
document.body.innerHTML = `<h1>${html}</h1>`;
|
|
14
|
+
return document.body.firstElementChild as HTMLElement;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A range over the host's text, by character offsets across the whole element. */
|
|
18
|
+
/** Every text node and line break in order, with the offset each one starts at. */
|
|
19
|
+
function charSpans(host: HTMLElement): Array<{ node: Node; from: number; length: number }> {
|
|
20
|
+
const spans: Array<{ node: Node; from: number; length: number }> = [];
|
|
21
|
+
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
22
|
+
let seen = 0;
|
|
23
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
24
|
+
const isBreak = node.nodeType === 1 && (node as Element).tagName === "BR";
|
|
25
|
+
if (node.nodeType === 1 && !isBreak) continue;
|
|
26
|
+
const length = isBreak ? 1 : (node.textContent?.length ?? 0);
|
|
27
|
+
spans.push({ node, from: seen, length });
|
|
28
|
+
seen += length;
|
|
29
|
+
}
|
|
30
|
+
return spans;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A range over the host's text, by character offsets across the whole element.
|
|
35
|
+
*
|
|
36
|
+
* A line break counts as one character, the same way the module does, but is
|
|
37
|
+
* never landed on: a boundary there belongs to the text beside it, which is
|
|
38
|
+
* where a real selection would put it too.
|
|
39
|
+
*/
|
|
40
|
+
function rangeOver(host: HTMLElement, start: number, end: number): Range {
|
|
41
|
+
const range = document.createRange();
|
|
42
|
+
const text = charSpans(host).filter((span) => span.node.nodeType === 3);
|
|
43
|
+
const at = (offset: number) => text.find((span) => span.from + span.length >= offset);
|
|
44
|
+
const from = at(start);
|
|
45
|
+
const to = at(end);
|
|
46
|
+
if (from) range.setStart(from.node, start - from.from);
|
|
47
|
+
if (from && to) range.setEnd(to.node, end - to.from);
|
|
48
|
+
return range;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Elements left holding half a character by a boundary that fell inside one. */
|
|
52
|
+
function elementsWithHalfACharacter(host: HTMLElement): string[] {
|
|
53
|
+
return Array.from(host.querySelectorAll("*"))
|
|
54
|
+
.map((node) => node.textContent ?? "")
|
|
55
|
+
.filter((text) => {
|
|
56
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
57
|
+
const code = text.charCodeAt(index);
|
|
58
|
+
if (code >= 0xdc00 && code <= 0xdfff) return true;
|
|
59
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
60
|
+
const next = text.charCodeAt(index + 1);
|
|
61
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("applyInlineStyle", () => {
|
|
70
|
+
it("styles exactly the characters selected, and nothing else", () => {
|
|
71
|
+
const host = mount("hello world");
|
|
72
|
+
|
|
73
|
+
applyInlineStyle(rangeOver(host, 6, 11), { color: "red" });
|
|
74
|
+
|
|
75
|
+
expect(host.innerHTML).toBe('hello <span style="color: red">world</span>');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("styles a run in the middle, leaving the text either side alone", () => {
|
|
79
|
+
const host = mount("abcdef");
|
|
80
|
+
|
|
81
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
82
|
+
|
|
83
|
+
expect(host.innerHTML).toBe('ab<span style="color: red">cd</span>ef');
|
|
84
|
+
expect(host.textContent).toBe("abcdef");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does nothing at all when nothing is selected", () => {
|
|
88
|
+
const host = mount("abc");
|
|
89
|
+
const range = rangeOver(host, 1, 1);
|
|
90
|
+
|
|
91
|
+
applyInlineStyle(range, { color: "red" });
|
|
92
|
+
|
|
93
|
+
expect(host.innerHTML).toBe("abc");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Left alone, every recolour would wrap the last one and the markup would
|
|
97
|
+
// grow without bound while only the innermost span had any effect.
|
|
98
|
+
it("replaces a colour rather than nesting a second span inside the first", () => {
|
|
99
|
+
const host = mount("abc");
|
|
100
|
+
|
|
101
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
102
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "blue" });
|
|
103
|
+
|
|
104
|
+
expect(host.innerHTML).toBe('<span style="color: blue">abc</span>');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("merges with the run beside it when the styling matches", () => {
|
|
108
|
+
const host = mount("abcd");
|
|
109
|
+
|
|
110
|
+
applyInlineStyle(rangeOver(host, 0, 2), { color: "red" });
|
|
111
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
112
|
+
|
|
113
|
+
expect(host.innerHTML).toBe('<span style="color: red">abcd</span>');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does not merge runs that only look alike", () => {
|
|
117
|
+
const host = mount("abcd");
|
|
118
|
+
|
|
119
|
+
applyInlineStyle(rangeOver(host, 0, 2), { color: "red" });
|
|
120
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "blue" });
|
|
121
|
+
|
|
122
|
+
expect(host.innerHTML).toBe(
|
|
123
|
+
'<span style="color: red">ab</span><span style="color: blue">cd</span>',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("leaves no empty span behind when the last style is taken off", () => {
|
|
128
|
+
const host = mount("abc");
|
|
129
|
+
|
|
130
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
131
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: null });
|
|
132
|
+
|
|
133
|
+
expect(host.innerHTML).toBe("abc");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("keeps a property the new styling does not mention", () => {
|
|
137
|
+
const host = mount("abc");
|
|
138
|
+
|
|
139
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
140
|
+
applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
|
|
141
|
+
|
|
142
|
+
expect(host.innerHTML).toContain("color: red");
|
|
143
|
+
expect(host.innerHTML).toContain("font-weight: 700");
|
|
144
|
+
expect(host.querySelectorAll("span")).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("styles across the boundary of a run that is already styled", () => {
|
|
148
|
+
const host = mount("abcdef");
|
|
149
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
150
|
+
|
|
151
|
+
applyInlineStyle(rangeOver(host, 1, 5), { "font-weight": "700" });
|
|
152
|
+
|
|
153
|
+
expect(host.textContent).toBe("abcdef");
|
|
154
|
+
expect(host.innerHTML).toContain("font-weight: 700");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("leaves the selection over the characters it just styled", () => {
|
|
158
|
+
const host = mount("hello world");
|
|
159
|
+
|
|
160
|
+
applyInlineStyle(rangeOver(host, 0, 5), { color: "red" });
|
|
161
|
+
|
|
162
|
+
expect(document.getSelection()?.toString()).toBe("hello");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("styles more than one property at once", () => {
|
|
166
|
+
const host = mount("abc");
|
|
167
|
+
|
|
168
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red", "font-style": "italic" });
|
|
169
|
+
|
|
170
|
+
expect(host.innerHTML).toContain("color: red");
|
|
171
|
+
expect(host.innerHTML).toContain("font-style: italic");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The rebuild is what keeps the markup from growing: whatever shape the
|
|
176
|
+
// element was in going in, it comes out as one span per distinct run.
|
|
177
|
+
describe("applyInlineStyle rebuilds rather than wraps", () => {
|
|
178
|
+
it("flattens markup that was already nested", () => {
|
|
179
|
+
const host = mount('<span style="color: red"><span style="color: red">abc</span></span>');
|
|
180
|
+
|
|
181
|
+
applyInlineStyle(rangeOver(host, 0, 3), { "font-style": "italic" });
|
|
182
|
+
|
|
183
|
+
expect(host.querySelectorAll("span")).toHaveLength(1);
|
|
184
|
+
expect(host.textContent).toBe("abc");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("leaves no span carrying nothing", () => {
|
|
188
|
+
const host = mount('a<span style="color: red"></span>b');
|
|
189
|
+
|
|
190
|
+
applyInlineStyle(rangeOver(host, 0, 2), { "font-style": "italic" });
|
|
191
|
+
|
|
192
|
+
expect(host.querySelectorAll("span")).toHaveLength(1);
|
|
193
|
+
expect(host.textContent).toBe("ab");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("reads a bold tag as styling and writes it back as one span", () => {
|
|
197
|
+
const host = mount("<b>abc</b>");
|
|
198
|
+
|
|
199
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
200
|
+
|
|
201
|
+
expect(host.querySelector("span")?.getAttribute("style")).toContain("font-weight: 700");
|
|
202
|
+
expect(host.querySelector("span")?.getAttribute("style")).toContain("color: red");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("keeps line breaks where they were", () => {
|
|
206
|
+
const host = mount("ab<br>cd");
|
|
207
|
+
|
|
208
|
+
applyInlineStyle(rangeOver(host, 0, 2), { color: "red" });
|
|
209
|
+
|
|
210
|
+
expect(host.querySelectorAll("br")).toHaveLength(1);
|
|
211
|
+
expect(host.innerHTML).toBe('<span style="color: red">ab</span><br>cd');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The bug: a chip is `display: flex`, so each span became its own flex item.
|
|
215
|
+
// Colouring one word broke the centring and rewrapped the whole line.
|
|
216
|
+
it("keeps a flex container's text as one item, so colouring a word cannot reflow it", () => {
|
|
217
|
+
const host = mount("Hello this is a test to see how this work");
|
|
218
|
+
host.style.display = "flex";
|
|
219
|
+
|
|
220
|
+
applyInlineStyle(rangeOver(host, 28, 31), { color: "red" });
|
|
221
|
+
|
|
222
|
+
expect(host.children).toHaveLength(1);
|
|
223
|
+
expect(host.firstElementChild?.tagName).toBe("SPAN");
|
|
224
|
+
expect(host.firstElementChild?.getAttribute("style")).toBeNull();
|
|
225
|
+
expect(host.querySelector("span span")?.textContent).toBe("how");
|
|
226
|
+
expect(host.textContent).toBe("Hello this is a test to see how this work");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("does the same for a grid container", () => {
|
|
230
|
+
const host = mount("abcdef");
|
|
231
|
+
host.style.display = "grid";
|
|
232
|
+
|
|
233
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
234
|
+
|
|
235
|
+
expect(host.children).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does not wrap an ordinary block, which flows its text already", () => {
|
|
239
|
+
const host = mount("abcdef");
|
|
240
|
+
|
|
241
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
242
|
+
|
|
243
|
+
expect(host.innerHTML).toBe('ab<span style="color: red">cd</span>ef');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reads styling back out of the wrapper it added", () => {
|
|
247
|
+
const host = mount("abcdef");
|
|
248
|
+
host.style.display = "flex";
|
|
249
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
250
|
+
|
|
251
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "blue" });
|
|
252
|
+
|
|
253
|
+
expect(host.querySelectorAll("span span")).toHaveLength(1);
|
|
254
|
+
expect(host.querySelector("span span")?.getAttribute("style")).toBe("color: blue");
|
|
255
|
+
expect(host.textContent).toBe("abcdef");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("styles a run that sits after a line break", () => {
|
|
259
|
+
const host = mount("ab<br>cd");
|
|
260
|
+
|
|
261
|
+
applyInlineStyle(rangeOver(host, 3, 5), { color: "red" });
|
|
262
|
+
|
|
263
|
+
expect(host.innerHTML).toBe('ab<br><span style="color: red">cd</span>');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("readInlineStyle", () => {
|
|
268
|
+
it("reports the styling of a run that is styled the same throughout", () => {
|
|
269
|
+
const host = mount('<span style="color: rgb(255, 0, 0)">abc</span>');
|
|
270
|
+
|
|
271
|
+
const styles = readInlineStyle(rangeOver(host, 0, 3), ["color"]);
|
|
272
|
+
|
|
273
|
+
expect(styles.color).toBe("rgb(255, 0, 0)");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("reports nothing for a property that is not set anywhere", () => {
|
|
277
|
+
const host = mount("abc");
|
|
278
|
+
|
|
279
|
+
const styles = readInlineStyle(rangeOver(host, 0, 3), ["background-color"]);
|
|
280
|
+
|
|
281
|
+
expect(styles["background-color"]).toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("reports the character before a collapsed caret at a style boundary", () => {
|
|
285
|
+
const host = mount('<span style="color: red">ab</span>cd');
|
|
286
|
+
|
|
287
|
+
const styles = readInlineStyle(rangeOver(host, 2, 2), ["color"]);
|
|
288
|
+
|
|
289
|
+
expect(styles.color).toBe("red");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Edge cases found by asking what a real composition contains that the happy
|
|
294
|
+
// path does not: source formatting, containers that box their children, text
|
|
295
|
+
// that is not plain ASCII, and a selection that reaches outside the element.
|
|
296
|
+
describe("applyInlineStyle edge cases", () => {
|
|
297
|
+
it("keeps a newline that came from the source file as text, not a line break", () => {
|
|
298
|
+
// Compositions are written across lines. Turning that whitespace into <br>
|
|
299
|
+
// would add visible breaks to an element that had none.
|
|
300
|
+
const host = mount("\n Hello world\n ");
|
|
301
|
+
|
|
302
|
+
applyInlineStyle(rangeOver(host, 7, 12), { color: "red" });
|
|
303
|
+
|
|
304
|
+
expect(host.querySelectorAll("br")).toHaveLength(0);
|
|
305
|
+
expect(host.textContent).toBe("\n Hello world\n ");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("still writes a real line break back as a line break", () => {
|
|
309
|
+
const host = mount("ab<br>cd");
|
|
310
|
+
|
|
311
|
+
applyInlineStyle(rangeOver(host, 3, 5), { color: "red" });
|
|
312
|
+
|
|
313
|
+
expect(host.querySelectorAll("br")).toHaveLength(1);
|
|
314
|
+
expect(host.textContent).toBe("abcd");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("keeps text that looks like markup as text", () => {
|
|
318
|
+
const host = mount("a <b> & c");
|
|
319
|
+
|
|
320
|
+
applyInlineStyle(rangeOver(host, 0, 1), { color: "red" });
|
|
321
|
+
|
|
322
|
+
expect(host.textContent).toBe("a <b> & c");
|
|
323
|
+
expect(host.querySelectorAll("b")).toHaveLength(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it.each(["a", "sub", "sup", "mark", "s"])(
|
|
327
|
+
"unwraps unsupported <%s> markup exactly as the persistence sanitizer does",
|
|
328
|
+
(tag) => {
|
|
329
|
+
const host = mount(`<${tag} href="https://example.com" style="color: red">abc</${tag}>`);
|
|
330
|
+
host.contentEditable = "true";
|
|
331
|
+
|
|
332
|
+
applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
|
|
333
|
+
|
|
334
|
+
expect(host.querySelector(tag)).toBeNull();
|
|
335
|
+
expect(host.innerHTML).not.toContain("href");
|
|
336
|
+
expect(host.innerHTML).not.toContain("color: red");
|
|
337
|
+
expect(host.innerHTML).toContain("font-weight: 700");
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
it("does not cut an emoji in half when the boundary lands inside one", () => {
|
|
342
|
+
// A selection offset is counted in UTF-16 units, and an emoji is two of
|
|
343
|
+
// them. Splitting one leaves half a character in each span.
|
|
344
|
+
const host = mount("ab👍cd");
|
|
345
|
+
|
|
346
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
347
|
+
|
|
348
|
+
expect(host.textContent).toBe("ab👍cd");
|
|
349
|
+
// textContent would read back whole even if the two halves sat in separate
|
|
350
|
+
// spans, so the check that matters is that no element holds half a one.
|
|
351
|
+
expect(elementsWithHalfACharacter(host)).toEqual([]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("keeps a whole emoji together when the selection starts inside one", () => {
|
|
355
|
+
const host = mount("ab👍cd");
|
|
356
|
+
|
|
357
|
+
applyInlineStyle(rangeOver(host, 3, 6), { color: "red" });
|
|
358
|
+
|
|
359
|
+
expect(host.textContent).toBe("ab👍cd");
|
|
360
|
+
expect(elementsWithHalfACharacter(host)).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it.each([
|
|
364
|
+
["a zero-width-joiner family", "👨👩👧"],
|
|
365
|
+
["a regional-indicator flag", "🇺🇸"],
|
|
366
|
+
["an emoji with a skin-tone modifier", "👍🏽"],
|
|
367
|
+
["a combining-mark character", "e\u0301"],
|
|
368
|
+
])("keeps %s together", (_name, grapheme) => {
|
|
369
|
+
const host = mount(`a${grapheme}b`);
|
|
370
|
+
// Both boundaries are expressed in UTF-16 units and at least one lands
|
|
371
|
+
// inside the grapheme rather than at one of its edges.
|
|
372
|
+
applyInlineStyle(rangeOver(host, 2, Math.max(3, grapheme.length)), { color: "red" });
|
|
373
|
+
|
|
374
|
+
expect(host.innerHTML).toBe(`a<span style="color: red">${grapheme}</span>b`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("leaves the element alone when the selection reaches outside it", () => {
|
|
378
|
+
// Rebuilding on a range whose common ancestor is an ancestor of the element
|
|
379
|
+
// would rewrite far more of the document than the user selected.
|
|
380
|
+
document.body.innerHTML = '<section><h1 id="a">first</h1><h1 id="b">second</h1></section>';
|
|
381
|
+
const section = document.body.firstElementChild as HTMLElement;
|
|
382
|
+
const before = section.innerHTML;
|
|
383
|
+
const range = document.createRange();
|
|
384
|
+
range.setStart(section.querySelector("#a")!.firstChild!, 1);
|
|
385
|
+
range.setEnd(section.querySelector("#b")!.firstChild!, 2);
|
|
386
|
+
|
|
387
|
+
applyInlineStyle(range, { color: "red" });
|
|
388
|
+
|
|
389
|
+
expect(section.innerHTML).toBe(before);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("counts a nested line break in a range whose boundaries are child indexes", () => {
|
|
393
|
+
const host = mount("a<span>b<br>c</span>d");
|
|
394
|
+
const range = document.createRange();
|
|
395
|
+
range.setStart(host, 1);
|
|
396
|
+
range.setEnd(host, 2);
|
|
397
|
+
|
|
398
|
+
applyInlineStyle(range, { color: "red" });
|
|
399
|
+
|
|
400
|
+
expect(Array.from(host.querySelectorAll("span")).map((span) => span.textContent)).toEqual([
|
|
401
|
+
"b",
|
|
402
|
+
"c",
|
|
403
|
+
]);
|
|
404
|
+
expect(host.innerHTML.endsWith("d")).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("walks deeply nested formatting without using the call stack", () => {
|
|
408
|
+
const host = mount("");
|
|
409
|
+
let parent = host;
|
|
410
|
+
for (let depth = 0; depth < 2_000; depth += 1) {
|
|
411
|
+
const span = document.createElement("span");
|
|
412
|
+
parent.append(span);
|
|
413
|
+
parent = span;
|
|
414
|
+
}
|
|
415
|
+
parent.textContent = "x";
|
|
416
|
+
|
|
417
|
+
applyInlineStyle(rangeOver(host, 0, 1), { color: "red" });
|
|
418
|
+
|
|
419
|
+
expect(host.textContent).toBe("x");
|
|
420
|
+
expect(host.querySelectorAll("span")).toHaveLength(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("keeps a line-clamped element's text as one item", () => {
|
|
424
|
+
// -webkit-box is how line clamping is written, and it boxes its children
|
|
425
|
+
// exactly like flex does.
|
|
426
|
+
const host = mount("abcdef");
|
|
427
|
+
host.style.display = "-webkit-box";
|
|
428
|
+
|
|
429
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
430
|
+
|
|
431
|
+
expect(host.children).toHaveLength(1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("styles the whole text when everything is selected", () => {
|
|
435
|
+
const host = mount("abcdef");
|
|
436
|
+
|
|
437
|
+
applyInlineStyle(rangeOver(host, 0, 6), { color: "red" });
|
|
438
|
+
|
|
439
|
+
expect(host.innerHTML).toBe('<span style="color: red">abcdef</span>');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("styles right up to an existing run's edge without merging into it", () => {
|
|
443
|
+
const host = mount("abcd");
|
|
444
|
+
applyInlineStyle(rangeOver(host, 0, 2), { color: "red" });
|
|
445
|
+
|
|
446
|
+
applyInlineStyle(rangeOver(host, 2, 4), { "font-style": "italic" });
|
|
447
|
+
|
|
448
|
+
expect(host.querySelectorAll("span")).toHaveLength(2);
|
|
449
|
+
expect(host.textContent).toBe("abcd");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("survives being asked to style the same run twice over", () => {
|
|
453
|
+
const host = mount("abcdef");
|
|
454
|
+
for (let round = 0; round < 5; round += 1) {
|
|
455
|
+
applyInlineStyle(rangeOver(host, 1, 4), { color: "red" });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
expect(host.querySelectorAll("span")).toHaveLength(1);
|
|
459
|
+
expect(host.textContent).toBe("abcdef");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* An element's children are not always anonymous formatting. The design panel
|
|
465
|
+
* keeps them as text layers and tracks each by an attribute on it, so a rebuild
|
|
466
|
+
* that emits fresh bare spans throws that identity away — and after colouring a
|
|
467
|
+
* single word the panel could no longer match a layer to its source, so every
|
|
468
|
+
* edit it offered failed with "Couldn't save this text structure change".
|
|
469
|
+
*/
|
|
470
|
+
describe("applyInlineStyle keeps what the design panel tracks", () => {
|
|
471
|
+
it("keeps a layer's key when the styling changes", () => {
|
|
472
|
+
const host = mount(
|
|
473
|
+
'<span data-hf-text-key="child:0" style="color: red">Hello</span>' +
|
|
474
|
+
'<span data-hf-text-key="child:1" style="color: blue">world</span>',
|
|
475
|
+
);
|
|
476
|
+
applyInlineStyle(rangeOver(host, 0, 5), { color: "green" });
|
|
477
|
+
|
|
478
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
|
|
479
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:1"');
|
|
480
|
+
expect(host.innerHTML).toContain("color: green");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("keeps a layer's typography, which the edit never mentioned", () => {
|
|
484
|
+
const host = mount(
|
|
485
|
+
'<span data-hf-text-key="child:0" style="font-size: 48px; color: red">Hello</span>',
|
|
486
|
+
);
|
|
487
|
+
applyInlineStyle(rangeOver(host, 0, 5), { color: "green" });
|
|
488
|
+
|
|
489
|
+
expect(host.innerHTML).toContain("font-size: 48px");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("does not put one layer's key on two spans when its text is split", () => {
|
|
493
|
+
const host = mount('<span data-hf-text-key="child:0">Hello</span>');
|
|
494
|
+
applyInlineStyle(rangeOver(host, 0, 2), { color: "green" });
|
|
495
|
+
|
|
496
|
+
expect(host.innerHTML.match(/data-hf-text-key="child:0"/g) ?? []).toHaveLength(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("still merges neighbours that carry no identity to lose", () => {
|
|
500
|
+
const host = mount('<span style="color: red">ab</span>cd');
|
|
501
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
502
|
+
|
|
503
|
+
expect(host.innerHTML).toBe('<span style="color: red">abcd</span>');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// The wrapper the rebuild adds inside a flex container carries nothing. Read
|
|
507
|
+
// as the layer it sits around, it hid the real one below it, and the second
|
|
508
|
+
// edit of an element threw away every identity the first one had kept.
|
|
509
|
+
it("keeps a layer's identity through a second edit inside a flex container", () => {
|
|
510
|
+
document.body.innerHTML =
|
|
511
|
+
'<div style="display: flex" contenteditable="true">' +
|
|
512
|
+
'<span data-hf-text-key="child:0">one<br>two</span></div>';
|
|
513
|
+
const host = document.body.firstElementChild as HTMLElement;
|
|
514
|
+
applyInlineStyle(rangeOver(host, 4, 6), { color: "red" });
|
|
515
|
+
const live = document.getSelection()?.getRangeAt(0);
|
|
516
|
+
if (live) applyInlineStyle(live, { color: "blue" });
|
|
517
|
+
|
|
518
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
|
|
519
|
+
expect(host.innerHTML).toContain("color: blue");
|
|
520
|
+
expect(host.innerHTML).not.toContain("color: red");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Stamped by the writer on every element on the way to disk, so carrying it
|
|
524
|
+
// preserves nothing — and it made the wrapper this rebuild adds look like a
|
|
525
|
+
// layer as soon as the file had been saved once.
|
|
526
|
+
it("does not treat the writer's own id as a layer identity", () => {
|
|
527
|
+
document.body.innerHTML =
|
|
528
|
+
'<div style="display: flex" contenteditable="true">' +
|
|
529
|
+
'<span data-hf-id="hf-wrap"><span data-hf-text-key="child:0">one<br>two</span></span></div>';
|
|
530
|
+
const host = document.body.firstElementChild as HTMLElement;
|
|
531
|
+
applyInlineStyle(rangeOver(host, 4, 6), { color: "red" });
|
|
532
|
+
|
|
533
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
|
|
534
|
+
expect(host.innerHTML).not.toContain("hf-wrap");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("does not carry attributes that the persistence sanitizer will remove", () => {
|
|
538
|
+
const host = mount(
|
|
539
|
+
'<span data-hf-text-key="child:0" aria-label="stale" onclick="alert(1)">abc</span>',
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
applyInlineStyle(rangeOver(host, 0, 3), { color: "red" });
|
|
543
|
+
|
|
544
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
|
|
545
|
+
expect(host.innerHTML).not.toContain("aria-label");
|
|
546
|
+
expect(host.innerHTML).not.toContain("onclick");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("does not carry style properties that the persistence sanitizer will remove", () => {
|
|
550
|
+
const host = mount('<span style="color: red; text-transform: uppercase">abc</span>');
|
|
551
|
+
|
|
552
|
+
applyInlineStyle(rangeOver(host, 0, 3), { "font-weight": "700" });
|
|
553
|
+
|
|
554
|
+
expect(host.innerHTML).toBe('<span style="color: red; font-weight: 700">abc</span>');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("does not edit through a contenteditable=false boundary", () => {
|
|
558
|
+
document.body.innerHTML =
|
|
559
|
+
'<div contenteditable="true"><span contenteditable="false">locked</span></div>';
|
|
560
|
+
const locked = document.body.querySelector("span") as HTMLElement;
|
|
561
|
+
|
|
562
|
+
applyInlineStyle(rangeOver(locked, 0, 6), { color: "red" });
|
|
563
|
+
|
|
564
|
+
expect(locked.innerHTML).toBe("locked");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("does not merge two tracked layers that end up looking alike", () => {
|
|
568
|
+
const host = mount(
|
|
569
|
+
'<span data-hf-text-key="child:0" style="color: red">ab</span>' +
|
|
570
|
+
'<span data-hf-text-key="child:1" style="color: blue">cd</span>',
|
|
571
|
+
);
|
|
572
|
+
applyInlineStyle(rangeOver(host, 2, 4), { color: "red" });
|
|
573
|
+
|
|
574
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:0"');
|
|
575
|
+
expect(host.innerHTML).toContain('data-hf-text-key="child:1"');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("cannot merge identities through delimiter-bearing attribute values", () => {
|
|
579
|
+
const host = mount(
|
|
580
|
+
'<span data-hf-text-key="a&data-hf-text-key=b">ab</span>' +
|
|
581
|
+
'<span data-hf-text-key="b">cd</span>',
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
applyInlineStyle(rangeOver(host, 0, 4), { color: "red" });
|
|
585
|
+
|
|
586
|
+
expect(host.querySelectorAll("span")).toHaveLength(2);
|
|
587
|
+
expect(host.querySelectorAll('[data-hf-text-key="b"]')).toHaveLength(1);
|
|
588
|
+
expect(host.innerHTML).not.toContain("a&");
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* A control that fires more than once per gesture, which the colour input does:
|
|
594
|
+
* a native picker reports every sample while the pointer moves in it, so one
|
|
595
|
+
* choice of colour arrives as a stream of them, each applied to whatever is
|
|
596
|
+
* selected at the time.
|
|
597
|
+
*/
|
|
598
|
+
describe("applyInlineStyle survives a control that fires repeatedly", () => {
|
|
599
|
+
it("restyles the same characters each time, in text containing a line break", () => {
|
|
600
|
+
const host = mount("1.<br>abcdefg");
|
|
601
|
+
// "cde", on the line after the break. Only the first sample knows where the
|
|
602
|
+
// user pointed; every one after it reads the selection back, which is what
|
|
603
|
+
// the toolbar does and what the restore has to have got right.
|
|
604
|
+
applyInlineStyle(rangeOver(host, 5, 8), { color: "rgb(1, 1, 1)" });
|
|
605
|
+
for (const color of ["rgb(2, 2, 2)", "rgb(3, 3, 3)"]) {
|
|
606
|
+
const live = document.getSelection()?.getRangeAt(0);
|
|
607
|
+
if (live) applyInlineStyle(live, { color });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
expect(host.innerHTML).toBe('1.<br>ab<span style="color: rgb(3, 3, 3)">cde</span>fg');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("puts the selection back over the characters it styled, past a break", () => {
|
|
614
|
+
const host = mount("1.<br>abcdefg");
|
|
615
|
+
applyInlineStyle(rangeOver(host, 5, 8), { color: "red" });
|
|
616
|
+
|
|
617
|
+
expect(document.getSelection()?.toString()).toBe("cde");
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* A colour that does not paint is the same to the user as one that did not save.
|
|
623
|
+
*
|
|
624
|
+
* `-webkit-text-fill-color` inherits and paints the glyph fill, so a composition
|
|
625
|
+
* that sets it on a text element wins over any `color` the editor puts on a run
|
|
626
|
+
* inside it. The run saved correctly and rendered in someone else's colour,
|
|
627
|
+
* which reads as the colour picker being broken.
|
|
628
|
+
*/
|
|
629
|
+
describe("applyInlineStyle when something else is painting the glyphs", () => {
|
|
630
|
+
function stubFill(fill: string | null | ((element: HTMLElement) => string | null)) {
|
|
631
|
+
const real = window.getComputedStyle.bind(window);
|
|
632
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(((element: Element) => {
|
|
633
|
+
const computed = real(element as HTMLElement);
|
|
634
|
+
const resolvedFill = typeof fill === "function" ? fill(element as HTMLElement) : fill;
|
|
635
|
+
return new Proxy(computed, {
|
|
636
|
+
get: (target, key) => {
|
|
637
|
+
if (key === "webkitTextFillColor") return resolvedFill ?? undefined;
|
|
638
|
+
if (key === "color") return (element as HTMLElement).style.color || "rgb(0, 0, 0)";
|
|
639
|
+
return Reflect.get(target, key);
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}) as typeof window.getComputedStyle);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
it("mirrors the colour into the fill when an ancestor is overpainting", () => {
|
|
646
|
+
const host = mount("Hello world");
|
|
647
|
+
stubFill("rgb(255, 255, 255)");
|
|
648
|
+
applyInlineStyle(rangeOver(host, 6, 11), { color: "rgb(255, 0, 149)" });
|
|
649
|
+
|
|
650
|
+
expect(host.innerHTML).toContain("-webkit-text-fill-color: rgb(255, 0, 149)");
|
|
651
|
+
expect(host.innerHTML).toContain("color: rgb(255, 0, 149)");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("leaves the markup alone when nothing is overpainting", () => {
|
|
655
|
+
const host = mount("Hello world");
|
|
656
|
+
stubFill("rgb(255, 0, 149)");
|
|
657
|
+
applyInlineStyle(rangeOver(host, 6, 11), { color: "rgb(255, 0, 149)" });
|
|
658
|
+
|
|
659
|
+
expect(host.innerHTML).not.toContain("-webkit-text-fill-color");
|
|
660
|
+
expect(host.innerHTML).toContain("color: rgb(255, 0, 149)");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("says nothing about a run that carries no colour", () => {
|
|
664
|
+
const host = mount("Hello world");
|
|
665
|
+
stubFill("rgb(255, 255, 255)");
|
|
666
|
+
applyInlineStyle(rangeOver(host, 6, 11), { "font-weight": "700" });
|
|
667
|
+
|
|
668
|
+
expect(host.innerHTML).not.toContain("-webkit-text-fill-color");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("mirrors only the run whose own ancestor path is overpainting", () => {
|
|
672
|
+
const host = mount(
|
|
673
|
+
'<span data-hf-text-key="child:0" style="color: blue">left</span>' +
|
|
674
|
+
'<span data-hf-text-key="child:1" style="color: green">right</span>',
|
|
675
|
+
);
|
|
676
|
+
stubFill((element) =>
|
|
677
|
+
element.textContent === "left" ? "rgb(255, 255, 255)" : element.style.color,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
applyInlineStyle(rangeOver(host, 4, 9), { color: "red" });
|
|
681
|
+
|
|
682
|
+
const [left, right] = Array.from(host.querySelectorAll<HTMLElement>("span"));
|
|
683
|
+
expect(left?.style.getPropertyValue("-webkit-text-fill-color")).toBe("blue");
|
|
684
|
+
expect(right?.style.getPropertyValue("-webkit-text-fill-color")).toBe("");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("drops a generated mirror when the ancestor stops overpainting", () => {
|
|
688
|
+
const host = mount("Hello world");
|
|
689
|
+
let overpainted = true;
|
|
690
|
+
stubFill((element) => (overpainted ? "rgb(255, 255, 255)" : element.style.color));
|
|
691
|
+
applyInlineStyle(rangeOver(host, 6, 11), { color: "red" });
|
|
692
|
+
expect(host.innerHTML).toContain("-webkit-text-fill-color");
|
|
693
|
+
|
|
694
|
+
overpainted = false;
|
|
695
|
+
const live = document.getSelection()?.getRangeAt(0);
|
|
696
|
+
if (live) applyInlineStyle(live, { "font-weight": "700" });
|
|
697
|
+
|
|
698
|
+
expect(host.innerHTML).not.toContain("-webkit-text-fill-color");
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe("readInlineStyleSpread", () => {
|
|
703
|
+
it("reports every distinct colour in the selection, in order", () => {
|
|
704
|
+
const host = mount(
|
|
705
|
+
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
expect(readInlineStyleSpread(rangeOver(host, 0, 10), "color")).toEqual(["red", "lime"]);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("reports a colour once, however many characters carry it", () => {
|
|
712
|
+
const host = mount('<span style="color: red">He</span><span style="color: red">llo</span>');
|
|
713
|
+
|
|
714
|
+
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual(["red"]);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it("reports only what the selection covers", () => {
|
|
718
|
+
const host = mount(
|
|
719
|
+
'<span style="color: red">Hello</span><span style="color: lime">world</span>',
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
expect(readInlineStyleSpread(rangeOver(host, 6, 10), "color")).toEqual(["lime"]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("ignores whitespace, which shows no colour at all", () => {
|
|
726
|
+
// Colour the whole element, then recolour one word: the whitespace around it
|
|
727
|
+
// keeps the first colour. It paints no glyph, so counting it puts a band of a
|
|
728
|
+
// colour nothing on screen is painted in at the edge of the swatch.
|
|
729
|
+
const host = mount(
|
|
730
|
+
'<span style="color: lime"> </span>' +
|
|
731
|
+
'<span style="color: red">Hello</span>' +
|
|
732
|
+
'<span style="color: lime"> world</span>',
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
expect(readInlineStyleSpread(rangeOver(host, 0, 12), "color")).toEqual(["red", "lime"]);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("is empty when the characters carry no colour of their own", () => {
|
|
739
|
+
const host = mount("Hello world");
|
|
740
|
+
|
|
741
|
+
expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([]);
|
|
742
|
+
});
|
|
743
|
+
});
|