@mosetta/editor 0.0.0-stage → 0.0.29
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/CHANGELOG.md +190 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
// THE MODULES OF A PAGE (ADR-0062) — an editor tool: what the document open in the canvas makes a
|
|
2
|
+
// reader's browser fetch, as a TREE by package. It reads the canvas's clean preview (the page as
|
|
3
|
+
// the server serves it, no editor in it). A package first — a distribution (`mosetta-ui 0.0.1`) or
|
|
4
|
+
// a package (`react 19.2.0`) — with what it weighs on the page; opened, its modules (the blocks, the
|
|
5
|
+
// entries) and theirs; a module opened, its dependencies, the ones a rule took over crossed out —
|
|
6
|
+
// and a dependency opens the same way, into its own. Then the page's images and fonts. At the foot,
|
|
7
|
+
// the first visit and the repeat one, each opening into its kinds. Above, the page's version rules.
|
|
8
|
+
// Every level orders by the column clicked (name, size, gzip); behind each row a bar shows the share
|
|
9
|
+
// of the first visit it weighs — green for a crumb, red for a third of the page.
|
|
10
|
+
//
|
|
11
|
+
// Sizes are what the files weigh as fetched (from the browser's cache — the canvas fetched them);
|
|
12
|
+
// gzip is a real gzip made here, for text only: images and fonts are compressed already.
|
|
13
|
+
import { defineComponentSide, markInternalTile, useDiOptional } from '@mosetta/react';
|
|
14
|
+
import type { IEditingState } from '@mosetta/content';
|
|
15
|
+
import { FormEvent, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
16
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
17
|
+
import { CANVAS_TOOL_TOKEN } from '../canvas/canvasTools';
|
|
18
|
+
import type { ICanvasToolContext } from '../canvas/canvasTools';
|
|
19
|
+
import {
|
|
20
|
+
addWeights, BUILT_IN_RULES, describeRule, formatBytes, isText, markUnusedFonts, nextOrder, ordered, pageModules, pageRulesOf, shareHue, readFontFaces, readServedPage, readStylesheet, readToolState, versionsOf, weightOf, withRule, writeToolState,
|
|
21
|
+
} from './servedPage';
|
|
22
|
+
import type { ICatalogItem, IDependencyEdge, IFileSize, IFontFace, ILoadedFont, IModuleRow, IOrder, IPackageGroup, IServedFile, IWeight, TFileKind } from './servedPage';
|
|
23
|
+
import './pageModules.css';
|
|
24
|
+
|
|
25
|
+
interface IMeasured {
|
|
26
|
+
readonly files: readonly IServedFile[];
|
|
27
|
+
readonly sizes: ReadonlyMap<string, IFileSize>;
|
|
28
|
+
readonly html: IFileSize;
|
|
29
|
+
/** the font faces the page's stylesheets declare */
|
|
30
|
+
readonly faces: readonly IFontFace[];
|
|
31
|
+
readonly catalog: readonly ICatalogItem[];
|
|
32
|
+
readonly at: Date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** the catalog of a store, fetched once per window */
|
|
36
|
+
const catalogs = new Map<string, Promise<ICatalogItem[]>>();
|
|
37
|
+
|
|
38
|
+
function catalogAt(url: string): Promise<ICatalogItem[]> {
|
|
39
|
+
let known = catalogs.get(url);
|
|
40
|
+
if (!known) {
|
|
41
|
+
known = fetch(url)
|
|
42
|
+
.then((res) => (res.ok ? res.json() as Promise<unknown> : []))
|
|
43
|
+
.then((raw) => (Array.isArray(raw) ? raw as ICatalogItem[] : []))
|
|
44
|
+
.catch(() => {
|
|
45
|
+
catalogs.delete(url);
|
|
46
|
+
return [];
|
|
47
|
+
});
|
|
48
|
+
catalogs.set(url, known);
|
|
49
|
+
}
|
|
50
|
+
return known;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** a real gzip of the bytes, where the browser can make one */
|
|
54
|
+
async function gzipped(bytes: ArrayBuffer): Promise<number | null> {
|
|
55
|
+
const Compression = (globalThis as { CompressionStream?: typeof CompressionStream }).CompressionStream;
|
|
56
|
+
const body = new Response(bytes).body;
|
|
57
|
+
if (!Compression || !body) return null;
|
|
58
|
+
return (await new Response(body.pipeThrough(new Compression('gzip'))).arrayBuffer()).byteLength;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** each item through `work`, a few at a time */
|
|
62
|
+
async function inTurns<T>(items: readonly T[], work: (item: T) => Promise<void>, width = 6): Promise<void> {
|
|
63
|
+
let next = 0;
|
|
64
|
+
const lane = async () => {
|
|
65
|
+
while (next < items.length) await work(items[next++]);
|
|
66
|
+
};
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(width, items.length) }, lane));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** what a file weighs — and a stylesheet's text, read for its fonts and images */
|
|
71
|
+
interface IMeasure {
|
|
72
|
+
readonly size: IFileSize;
|
|
73
|
+
readonly text?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Each file measured ONCE per window: an address names one content for good (the version in the
|
|
78
|
+
* module's name, the content hash in `?v=`), so opening the tool again asks for the page and
|
|
79
|
+
* nothing else — after an edit, for what the edit brought. A file that could not be read is asked
|
|
80
|
+
* again next time.
|
|
81
|
+
*/
|
|
82
|
+
const measures = new Map<string, Promise<IMeasure | null>>();
|
|
83
|
+
|
|
84
|
+
function measureFile(file: IServedFile): Promise<IMeasure | null> {
|
|
85
|
+
const known = measures.get(file.url);
|
|
86
|
+
if (known) return known;
|
|
87
|
+
const made = (async (): Promise<IMeasure | null> => {
|
|
88
|
+
// a copy the browser kept from a load without CORS (an old one, the loader's <script>) fails a
|
|
89
|
+
// CORS read: asked again past the cache
|
|
90
|
+
const got = await fetch(file.url).catch(() => fetch(file.url, { cache: 'no-store' }));
|
|
91
|
+
if (!got.ok) return null;
|
|
92
|
+
const bytes = await got.arrayBuffer();
|
|
93
|
+
return {
|
|
94
|
+
size: { bytes: bytes.byteLength, gzip: isText(file) ? await gzipped(bytes) : null },
|
|
95
|
+
...(file.kind === 'css' ? { text: new TextDecoder().decode(bytes) } : {}),
|
|
96
|
+
};
|
|
97
|
+
})().catch(() => null);
|
|
98
|
+
measures.set(file.url, made);
|
|
99
|
+
void made.then((measure) => {
|
|
100
|
+
if (!measure) measures.delete(file.url);
|
|
101
|
+
});
|
|
102
|
+
return made;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The page as the server serves it, every file it fetches measured. The fonts its styles declare
|
|
107
|
+
* are matched with the faces the preview loaded first: one the text does not use is never fetched
|
|
108
|
+
* here either — it is listed, not weighed.
|
|
109
|
+
*/
|
|
110
|
+
async function measurePage(pageUrl: string, signal: AbortSignal, loadedFonts: () => Promise<readonly ILoadedFont[]>): Promise<IMeasured> {
|
|
111
|
+
const res = await fetch(pageUrl, { credentials: 'same-origin', signal });
|
|
112
|
+
if (!res.ok) throw new Error(`the preview answered ${res.status}`);
|
|
113
|
+
const page = await res.arrayBuffer();
|
|
114
|
+
const html: IFileSize = { bytes: page.byteLength, gzip: await gzipped(page) };
|
|
115
|
+
const served = readServedPage(new TextDecoder().decode(page), res.url || pageUrl);
|
|
116
|
+
const module = served.files.find((file) => file.moduleId);
|
|
117
|
+
const catalog = module ? await catalogAt(`${module.url.slice(0, module.url.indexOf('/modules/') + 1)}modules.json`) : [];
|
|
118
|
+
const sizes = new Map<string, IFileSize>();
|
|
119
|
+
const faces: IFontFace[] = [];
|
|
120
|
+
const named: IServedFile[] = [];
|
|
121
|
+
await inTurns(served.files, async (file) => {
|
|
122
|
+
const measure = await measureFile(file);
|
|
123
|
+
if (!measure) return;
|
|
124
|
+
sizes.set(file.url, measure.size);
|
|
125
|
+
if (measure.text === undefined) return;
|
|
126
|
+
// a stylesheet fetches in its turn what its url()s name
|
|
127
|
+
faces.push(...readFontFaces(measure.text, file.url));
|
|
128
|
+
for (const more of readStylesheet(measure.text, file.url)) {
|
|
129
|
+
if (!served.files.some((known) => known.url === more.url) && !named.some((known) => known.url === more.url)) named.push(more);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (signal.aborted) throw new DOMException('the page is read again', 'AbortError');
|
|
133
|
+
const files = markUnusedFonts([...served.files, ...named], faces, faces.length ? await loadedFonts() : []);
|
|
134
|
+
if (signal.aborted) throw new DOMException('the page is read again', 'AbortError');
|
|
135
|
+
await inTurns(files.slice(served.files.length).filter((file) => !file.unused), async (file) => {
|
|
136
|
+
const measure = await measureFile(file);
|
|
137
|
+
if (measure) sizes.set(file.url, measure.size);
|
|
138
|
+
});
|
|
139
|
+
return { files, sizes, html, faces, catalog, at: new Date() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const KIND_LABELS: Readonly<Record<TFileKind, string>> = { js: 'JS', css: 'CSS', image: 'Images', font: 'Fonts', other: 'Other' };
|
|
143
|
+
|
|
144
|
+
function Weight({ weight, testId, quiet }: { weight: IWeight; testId?: string; quiet?: boolean }) {
|
|
145
|
+
return (
|
|
146
|
+
<span className={`page-modules__weight${quiet ? ' page-modules__weight--quiet' : ''}`}
|
|
147
|
+
data-test-id={testId}
|
|
148
|
+
data-bytes={weight.bytes}>
|
|
149
|
+
<span className="page-modules__bytes">{formatBytes(weight.bytes)}</span>
|
|
150
|
+
<span className="page-modules__gzip">{weight.gzip === null ? '' : `${formatBytes(weight.gzip)} gz`}</span>
|
|
151
|
+
</span>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ChevronIcon = (
|
|
156
|
+
<svg viewBox="0 0 16 16"
|
|
157
|
+
aria-hidden="true">
|
|
158
|
+
<path d="M6 3.5L10.5 8 6 12.5"
|
|
159
|
+
fill="none"
|
|
160
|
+
stroke="currentColor"
|
|
161
|
+
strokeWidth="1.6"
|
|
162
|
+
strokeLinecap="round"
|
|
163
|
+
strokeLinejoin="round" />
|
|
164
|
+
</svg>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One line of the tree — its chevron (when it opens), what it is, what it weighs — and, open, what
|
|
169
|
+
* is under it. `open` null: nothing to open.
|
|
170
|
+
*/
|
|
171
|
+
function TreeRow({ depth, open, onToggle, name, meta, weight, weightTestId, share, quiet, testId, data, children }: {
|
|
172
|
+
depth: number;
|
|
173
|
+
open: boolean | null;
|
|
174
|
+
onToggle?: () => void;
|
|
175
|
+
name: ReactNode;
|
|
176
|
+
meta?: ReactNode;
|
|
177
|
+
weight?: IWeight;
|
|
178
|
+
weightTestId?: string;
|
|
179
|
+
/** the share of the first visit it weighs — the bar behind the row; none: no bar */
|
|
180
|
+
share?: number;
|
|
181
|
+
quiet?: boolean;
|
|
182
|
+
testId?: string;
|
|
183
|
+
data?: Record<string, string | undefined>;
|
|
184
|
+
children?: ReactNode;
|
|
185
|
+
}) {
|
|
186
|
+
return (
|
|
187
|
+
<div className={`page-modules__node${quiet ? ' page-modules__node--quiet' : ''}`}
|
|
188
|
+
data-test-id={testId}
|
|
189
|
+
{...data}>
|
|
190
|
+
<div className={`page-modules__line${share === undefined ? '' : ' page-modules__line--bar'}`}
|
|
191
|
+
style={{
|
|
192
|
+
'--depth': depth,
|
|
193
|
+
...(share === undefined ? {} : { '--share': `${Math.min(100, share * 100).toFixed(2)}%`, '--hue': shareHue(share) }),
|
|
194
|
+
} as CSSProperties}
|
|
195
|
+
onClick={open === null ? undefined : onToggle}>
|
|
196
|
+
{open === null ? <span className="page-modules__opener page-modules__opener--none" /> : (
|
|
197
|
+
<button type="button"
|
|
198
|
+
className={`page-modules__opener${open ? ' page-modules__opener--open' : ''}`}
|
|
199
|
+
data-test-id="page-modules-toggle"
|
|
200
|
+
aria-expanded={open}
|
|
201
|
+
aria-label={open ? 'Fold' : 'Unfold'}
|
|
202
|
+
onClick={(event) => {
|
|
203
|
+
event.stopPropagation();
|
|
204
|
+
onToggle?.();
|
|
205
|
+
}}>
|
|
206
|
+
{ChevronIcon}
|
|
207
|
+
</button>
|
|
208
|
+
)}
|
|
209
|
+
<span className="page-modules__name">{name}</span>
|
|
210
|
+
<span className="page-modules__meta">{meta}</span>
|
|
211
|
+
{weight ? <Weight weight={weight}
|
|
212
|
+
testId={weightTestId}
|
|
213
|
+
quiet={quiet} /> : null}
|
|
214
|
+
</div>
|
|
215
|
+
{open ? children : null}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** what the rows of the tree share: where an edge leads, the sizes, which rows are open */
|
|
221
|
+
interface ITree {
|
|
222
|
+
readonly byId: ReadonlyMap<string, IModuleRow>;
|
|
223
|
+
readonly sizes: ReadonlyMap<string, IFileSize>;
|
|
224
|
+
readonly order: IOrder | null;
|
|
225
|
+
isOpen(key: string): boolean;
|
|
226
|
+
toggle(key: string): void;
|
|
227
|
+
/** the share of the first visit a weight is */
|
|
228
|
+
shareOf(weight: IWeight): number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const NOTHING: IWeight = { count: 0, bytes: 0, gzip: 0 };
|
|
232
|
+
|
|
233
|
+
const kindsOf = (files: readonly IServedFile[]): string => [...new Set(files.map((file) => KIND_LABELS[file.kind].toLowerCase()))].join(' + ');
|
|
234
|
+
|
|
235
|
+
function VersionOf({ edge }: { edge: IDependencyEdge }) {
|
|
236
|
+
if (edge.given === null) return <span className="page-modules__version page-modules__version--absent">{`${edge.asked} · not fetched up front`}</span>;
|
|
237
|
+
if (!edge.crossed) return <span className="page-modules__version">{edge.asked}</span>;
|
|
238
|
+
return (
|
|
239
|
+
<span className={`page-modules__version page-modules__version--crossed${edge.lower ? ' page-modules__version--lower' : ''}`}
|
|
240
|
+
data-tooltip={edge.lower
|
|
241
|
+
? `Asks for ${edge.asked}; the page gives it an older ${edge.given} — a rule pins it lower`
|
|
242
|
+
: `Asks for ${edge.asked}; the page runs ${edge.given} — one copy for the whole page`}>
|
|
243
|
+
<s>{edge.asked}</s>
|
|
244
|
+
{` → ${edge.given}`}
|
|
245
|
+
</span>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** a module's edges in the tree's order — weighed by what loads for each */
|
|
250
|
+
function edgesInOrder(edges: readonly IDependencyEdge[], tree: ITree): IDependencyEdge[] {
|
|
251
|
+
return ordered(edges, tree.order, (edge) => edge.label, (edge) => {
|
|
252
|
+
const target = edge.loadedId ? tree.byId.get(edge.loadedId) : undefined;
|
|
253
|
+
return target ? weightOf(target.files, tree.sizes) : NOTHING;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** a dependency: what it asked for and what loads; opened, the dependencies of what loads */
|
|
258
|
+
function DependencyNode({ edge, depth, path, ancestors, tree }: { edge: IDependencyEdge; depth: number; path: string; ancestors: readonly string[]; tree: ITree }) {
|
|
259
|
+
const target = edge.loadedId ? tree.byId.get(edge.loadedId) : undefined;
|
|
260
|
+
const key = `${path}>${edge.label}`;
|
|
261
|
+
// a dependency already open above leads in a circle — it does not open again
|
|
262
|
+
const openable = Boolean(target && target.dependencies.length && !ancestors.includes(target.id));
|
|
263
|
+
const open = openable ? tree.isOpen(key) : null;
|
|
264
|
+
const weight = target ? weightOf(target.files, tree.sizes) : undefined;
|
|
265
|
+
return (
|
|
266
|
+
<TreeRow depth={depth}
|
|
267
|
+
open={open}
|
|
268
|
+
onToggle={() => tree.toggle(key)}
|
|
269
|
+
name={edge.label}
|
|
270
|
+
meta={<VersionOf edge={edge} />}
|
|
271
|
+
weight={weight}
|
|
272
|
+
share={weight ? tree.shareOf(weight) : undefined}
|
|
273
|
+
testId="page-modules-dep"
|
|
274
|
+
data={{ 'data-dep': edge.label, 'data-crossed': edge.crossed ? '' : undefined, 'data-lower': edge.lower ? '' : undefined }}>
|
|
275
|
+
{target && open ? edgesInOrder(target.dependencies, tree).map((next) => <DependencyNode key={next.id}
|
|
276
|
+
edge={next}
|
|
277
|
+
depth={depth + 1}
|
|
278
|
+
path={key}
|
|
279
|
+
ancestors={[...ancestors, target.id]}
|
|
280
|
+
tree={tree} />) : null}
|
|
281
|
+
</TreeRow>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** a module of a package: a block, an entry; opened, its dependencies */
|
|
286
|
+
function ModuleNode({ row, group, depth, tree }: { row: IModuleRow; group: string; depth: number; tree: ITree }) {
|
|
287
|
+
const key = `${group}/${row.title}`;
|
|
288
|
+
const open = row.dependencies.length ? tree.isOpen(key) : null;
|
|
289
|
+
const weight = weightOf(row.files, tree.sizes);
|
|
290
|
+
return (
|
|
291
|
+
<TreeRow depth={depth}
|
|
292
|
+
open={open}
|
|
293
|
+
onToggle={() => tree.toggle(key)}
|
|
294
|
+
name={row.title}
|
|
295
|
+
meta={kindsOf(row.files)}
|
|
296
|
+
weight={weight}
|
|
297
|
+
share={tree.shareOf(weight)}
|
|
298
|
+
testId="page-modules-module"
|
|
299
|
+
data={{ 'data-module': row.id, 'data-kind': row.kind }}>
|
|
300
|
+
{open ? edgesInOrder(row.dependencies, tree).map((edge) => <DependencyNode key={edge.id}
|
|
301
|
+
edge={edge}
|
|
302
|
+
depth={depth + 1}
|
|
303
|
+
path={key}
|
|
304
|
+
ancestors={[row.id]}
|
|
305
|
+
tree={tree} />) : null}
|
|
306
|
+
</TreeRow>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const fileName = (url: string): string => decodeURIComponent(new URL(url).pathname.split('/').pop() ?? url);
|
|
311
|
+
|
|
312
|
+
/** a package of the page: a tile palette or a package, what it weighs here; opened, its tiles */
|
|
313
|
+
function GroupNode({ group, depth, tree }: { group: IPackageGroup; depth: number; tree: ITree }) {
|
|
314
|
+
const key = group.key;
|
|
315
|
+
const weight = weightOf(group.modules.flatMap((row) => row.files), tree.sizes);
|
|
316
|
+
const noun = group.kind === 'distribution' ? 'block' : 'tile';
|
|
317
|
+
const open = tree.isOpen(key);
|
|
318
|
+
return (
|
|
319
|
+
<TreeRow depth={depth}
|
|
320
|
+
open={open}
|
|
321
|
+
onToggle={() => tree.toggle(key)}
|
|
322
|
+
name={group.name}
|
|
323
|
+
meta={`${group.version} · ${group.modules.length} ${noun}${group.modules.length === 1 ? '' : 's'}`}
|
|
324
|
+
weight={weight}
|
|
325
|
+
share={tree.shareOf(weight)}
|
|
326
|
+
testId="page-modules-group"
|
|
327
|
+
data={{ 'data-group': group.key, 'data-kind': group.kind }}>
|
|
328
|
+
{open ? ordered(group.modules, tree.order, (row) => row.title, (row) => weightOf(row.files, tree.sizes)).map((row) => <ModuleNode key={row.id}
|
|
329
|
+
row={row}
|
|
330
|
+
group={group.key}
|
|
331
|
+
depth={depth + 1}
|
|
332
|
+
tree={tree} />) : null}
|
|
333
|
+
</TreeRow>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** files of no module, by kind — the loader, the page's images, the fonts its styles declare */
|
|
338
|
+
function FilesNode({ name, files, counted, tree, meta }: { name: string; files: readonly IServedFile[]; counted: readonly IServedFile[]; tree: ITree; meta: string }) {
|
|
339
|
+
const key = `files:${name}`;
|
|
340
|
+
const weight = weightOf(counted, tree.sizes);
|
|
341
|
+
return (
|
|
342
|
+
<TreeRow depth={0}
|
|
343
|
+
open={tree.isOpen(key)}
|
|
344
|
+
onToggle={() => tree.toggle(key)}
|
|
345
|
+
name={name}
|
|
346
|
+
meta={meta}
|
|
347
|
+
weight={weight}
|
|
348
|
+
share={tree.shareOf(weight)}
|
|
349
|
+
testId="page-modules-files"
|
|
350
|
+
data={{ 'data-files': name }}>
|
|
351
|
+
{ordered(files, tree.order, (file) => fileName(file.url), (file) => weightOf([file], tree.sizes)).map((file) => (
|
|
352
|
+
<TreeRow key={file.url}
|
|
353
|
+
depth={1}
|
|
354
|
+
open={null}
|
|
355
|
+
name={fileName(file.url)}
|
|
356
|
+
meta={file.unused ? 'the text does not use it — not fetched' : file.lazy ? 'when it comes into view' : KIND_LABELS[file.kind]}
|
|
357
|
+
// a font the text does not use is never fetched — listed, not weighed
|
|
358
|
+
weight={tree.sizes.has(file.url) ? weightOf([file], tree.sizes) : undefined}
|
|
359
|
+
share={tree.sizes.has(file.url) && !file.unused ? tree.shareOf(weightOf([file], tree.sizes)) : undefined}
|
|
360
|
+
quiet={file.unused}
|
|
361
|
+
testId="page-modules-asset" />
|
|
362
|
+
))}
|
|
363
|
+
</TreeRow>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function Rules({ tool, state, catalog, specifiers, tree }: {
|
|
368
|
+
tool: ICanvasToolContext;
|
|
369
|
+
state: IEditingState;
|
|
370
|
+
catalog: readonly ICatalogItem[];
|
|
371
|
+
specifiers: readonly string[];
|
|
372
|
+
tree: ITree;
|
|
373
|
+
}) {
|
|
374
|
+
const doc = state.doc;
|
|
375
|
+
const own = pageRulesOf(doc?.settings);
|
|
376
|
+
const editable = state.revision == null && doc !== null;
|
|
377
|
+
const [key, setKey] = useState('');
|
|
378
|
+
const [mode, setMode] = useState<'highest' | 'exact'>('highest');
|
|
379
|
+
const [version, setVersion] = useState('');
|
|
380
|
+
const [error, setError] = useState<string | null>(null);
|
|
381
|
+
const wildcard = key.trim().endsWith('/*');
|
|
382
|
+
const versions = useMemo(() => (key.trim() && !wildcard ? versionsOf(catalog, key.trim()) : []), [catalog, key, wildcard]);
|
|
383
|
+
const set = (name: string, rule: Parameters<typeof withRule>[2]) => {
|
|
384
|
+
if (!doc) return;
|
|
385
|
+
tool.session.updateNodeSettings(doc.id, withRule(doc.settings, name, rule));
|
|
386
|
+
};
|
|
387
|
+
const add = (event: FormEvent) => {
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
const name = key.trim();
|
|
390
|
+
if (!name) {
|
|
391
|
+
setError('Name a package — @scope/name, or @scope/* for all of a scope');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const exact = mode === 'exact' && !wildcard;
|
|
395
|
+
if (exact && !(version || versions[0])) {
|
|
396
|
+
setError(`No tile host holds a version of ${name}`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
set(name, { singleton: true, version: exact ? version || versions[0] : 'highest' });
|
|
400
|
+
setKey('');
|
|
401
|
+
setVersion('');
|
|
402
|
+
setError(null);
|
|
403
|
+
};
|
|
404
|
+
const count = Object.keys(own).length;
|
|
405
|
+
return (
|
|
406
|
+
<section className="page-modules__section"
|
|
407
|
+
data-test-id="page-modules-rules">
|
|
408
|
+
<TreeRow depth={0}
|
|
409
|
+
open={tree.isOpen('rules')}
|
|
410
|
+
onToggle={() => tree.toggle('rules')}
|
|
411
|
+
name="Rules"
|
|
412
|
+
meta={`which version of a package the page runs · built in${count ? ` + ${count} of this page` : ''}`}>
|
|
413
|
+
<div className="page-modules__rules">
|
|
414
|
+
{Object.entries(BUILT_IN_RULES).map(([name, rule]) => (
|
|
415
|
+
<div key={name}
|
|
416
|
+
className="page-modules__rule"
|
|
417
|
+
data-test-id="page-modules-rule"
|
|
418
|
+
data-rule={name}
|
|
419
|
+
data-built-in>
|
|
420
|
+
<code className="page-modules__key">{name}</code>
|
|
421
|
+
<span className="page-modules__rule-text">{describeRule(rule)}</span>
|
|
422
|
+
<span className="page-modules__tag">built in</span>
|
|
423
|
+
</div>
|
|
424
|
+
))}
|
|
425
|
+
{Object.entries(own).map(([name, rule]) => (
|
|
426
|
+
<div key={name}
|
|
427
|
+
className="page-modules__rule"
|
|
428
|
+
data-test-id="page-modules-rule"
|
|
429
|
+
data-rule={name}>
|
|
430
|
+
<code className="page-modules__key">{name}</code>
|
|
431
|
+
<span className="page-modules__rule-text">{describeRule(rule)}</span>
|
|
432
|
+
{editable ? (
|
|
433
|
+
<button type="button"
|
|
434
|
+
className="page-modules__link"
|
|
435
|
+
data-test-id="page-modules-rule-remove"
|
|
436
|
+
onClick={() => set(name, null)}>
|
|
437
|
+
Remove
|
|
438
|
+
</button>
|
|
439
|
+
) : <span className="page-modules__tag page-modules__tag--quiet">this page</span>}
|
|
440
|
+
</div>
|
|
441
|
+
))}
|
|
442
|
+
{editable ? (
|
|
443
|
+
<form className="page-modules__add"
|
|
444
|
+
data-test-id="page-modules-rule-add"
|
|
445
|
+
onSubmit={add}>
|
|
446
|
+
<input className="page-modules__input"
|
|
447
|
+
data-test-id="page-modules-rule-key"
|
|
448
|
+
list="page-modules-specifiers"
|
|
449
|
+
placeholder="@mosetta/di or @scope/*"
|
|
450
|
+
value={key}
|
|
451
|
+
aria-label="Package"
|
|
452
|
+
onChange={(event) => {
|
|
453
|
+
setKey(event.target.value);
|
|
454
|
+
setVersion('');
|
|
455
|
+
setError(null);
|
|
456
|
+
}} />
|
|
457
|
+
<datalist id="page-modules-specifiers">
|
|
458
|
+
{specifiers.map((name) => <option key={name}
|
|
459
|
+
value={name} />)}
|
|
460
|
+
</datalist>
|
|
461
|
+
<select className="page-modules__input"
|
|
462
|
+
data-test-id="page-modules-rule-mode"
|
|
463
|
+
value={wildcard ? 'highest' : mode}
|
|
464
|
+
disabled={wildcard}
|
|
465
|
+
aria-label="Which version"
|
|
466
|
+
onChange={(event) => setMode(event.target.value === 'exact' ? 'exact' : 'highest')}>
|
|
467
|
+
<option value="highest">one copy, the newest asked</option>
|
|
468
|
+
<option value="exact">one copy, exactly</option>
|
|
469
|
+
</select>
|
|
470
|
+
{mode === 'exact' && !wildcard ? (
|
|
471
|
+
<select className="page-modules__input"
|
|
472
|
+
data-test-id="page-modules-rule-version"
|
|
473
|
+
value={version || versions[0] || ''}
|
|
474
|
+
aria-label="Version"
|
|
475
|
+
onChange={(event) => setVersion(event.target.value)}>
|
|
476
|
+
{versions.length ? versions.map((v) => <option key={v}
|
|
477
|
+
value={v}>{v}</option>) : <option value="">no versions known</option>}
|
|
478
|
+
</select>
|
|
479
|
+
) : null}
|
|
480
|
+
<button type="submit"
|
|
481
|
+
className="page-modules__button"
|
|
482
|
+
data-test-id="page-modules-rule-submit">
|
|
483
|
+
Add rule
|
|
484
|
+
</button>
|
|
485
|
+
{error ? <span className="page-modules__error"
|
|
486
|
+
role="alert">{error}</span> : null}
|
|
487
|
+
</form>
|
|
488
|
+
) : (
|
|
489
|
+
<p className="page-modules__note">A published revision — make a draft of it to change its rules.</p>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
</TreeRow>
|
|
493
|
+
</section>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function Panel({ tool }: { tool: ICanvasToolContext }) {
|
|
498
|
+
const state = useSyncExternalStore(tool.session.subscribe, tool.session.snapshot, tool.session.snapshot);
|
|
499
|
+
const [measured, setMeasured] = useState<IMeasured | null>(null);
|
|
500
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
501
|
+
const [busy, setBusy] = useState(false);
|
|
502
|
+
const [asked, setAsked] = useState(0);
|
|
503
|
+
// the preview answers the SAVED document: read again on open, after each save, and on demand
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
const abort = new AbortController();
|
|
506
|
+
setBusy(true);
|
|
507
|
+
measurePage(new URL(tool.previewHref(), window.location.href).href, abort.signal, () => tool.loadedFonts())
|
|
508
|
+
.then((done) => {
|
|
509
|
+
setMeasured(done);
|
|
510
|
+
setFailure(null);
|
|
511
|
+
})
|
|
512
|
+
.catch((error: unknown) => {
|
|
513
|
+
if (!abort.signal.aborted) setFailure(error instanceof Error ? error.message : String(error));
|
|
514
|
+
})
|
|
515
|
+
.finally(() => {
|
|
516
|
+
if (!abort.signal.aborted) setBusy(false);
|
|
517
|
+
});
|
|
518
|
+
return () => abort.abort();
|
|
519
|
+
}, [tool, state.savedCount, asked]);
|
|
520
|
+
|
|
521
|
+
const catalogById = useMemo(() => new Map((measured?.catalog ?? []).map((item) => [item.id, item])), [measured]);
|
|
522
|
+
// the fonts the text never asks for are marked: declared, not fetched
|
|
523
|
+
const files = useMemo(() => measured?.files ?? [], [measured]);
|
|
524
|
+
const counted = useMemo(() => files.filter((file) => !file.unused), [files]);
|
|
525
|
+
const modules = useMemo(() => (measured ? pageModules(files, catalogById) : null), [measured, files, catalogById]);
|
|
526
|
+
const sizes = useMemo(() => measured?.sizes ?? new Map<string, IFileSize>(), [measured]);
|
|
527
|
+
const specifiers = useMemo(() => [...new Set([...(modules?.byId.values() ?? [])].filter((row) => row.kind === 'package').map((row) => row.title))].sort(), [modules]);
|
|
528
|
+
// what was open — the order, the open rows — comes back from the address, and goes there
|
|
529
|
+
const [said] = useState(() => readToolState(tool.readState()));
|
|
530
|
+
const [order, setOrder] = useState<IOrder | null>(said.order);
|
|
531
|
+
// as the page names them — the distributions, then the packages, the heaviest first — until a
|
|
532
|
+
// column says otherwise
|
|
533
|
+
const groups = useMemo(() => {
|
|
534
|
+
const all = modules?.groups ?? [];
|
|
535
|
+
const weight = (group: IPackageGroup) => weightOf(group.modules.flatMap((row) => row.files), sizes);
|
|
536
|
+
if (order) return ordered(all, order, (group) => group.name, weight);
|
|
537
|
+
return [...all.filter((g) => g.kind === 'distribution'), ...all.filter((g) => g.kind === 'package').sort((x, y) => weight(y).bytes - weight(x).bytes)];
|
|
538
|
+
}, [modules, sizes, order]);
|
|
539
|
+
const [opened, setOpened] = useState<ReadonlySet<string>>(said.opened);
|
|
540
|
+
useEffect(() => tool.writeState(writeToolState(order, opened)), [tool, order, opened]);
|
|
541
|
+
const toggle = useCallback((key: string) => setOpened((was) => {
|
|
542
|
+
const next = new Set(was);
|
|
543
|
+
if (next.has(key)) next.delete(key);
|
|
544
|
+
else next.add(key);
|
|
545
|
+
return next;
|
|
546
|
+
}), []);
|
|
547
|
+
const html: IWeight = measured ? { count: 1, bytes: measured.html.bytes, gzip: measured.html.gzip } : { count: 0, bytes: 0, gzip: 0 };
|
|
548
|
+
const first = addWeights(html, weightOf(counted, sizes));
|
|
549
|
+
const tree: ITree = {
|
|
550
|
+
byId: modules?.byId ?? new Map(),
|
|
551
|
+
sizes,
|
|
552
|
+
order,
|
|
553
|
+
isOpen: (key) => opened.has(key),
|
|
554
|
+
toggle,
|
|
555
|
+
shareOf: (weight) => (first.bytes ? weight.bytes / first.bytes : 0),
|
|
556
|
+
};
|
|
557
|
+
const byKind = (['js', 'css', 'font', 'image'] as const).map((kind) => ({
|
|
558
|
+
kind,
|
|
559
|
+
weight: weightOf(counted.filter((file) => file.kind === kind), sizes),
|
|
560
|
+
used: counted.filter((file) => file.kind === kind).length,
|
|
561
|
+
declared: files.filter((file) => file.kind === kind).length,
|
|
562
|
+
})).filter(({ declared }) => declared > 0);
|
|
563
|
+
// a file the browser could not read for us (refused, gone) is in no sum — said, not hidden
|
|
564
|
+
const unread = counted.filter((file) => !sizes.has(file.url)).length;
|
|
565
|
+
const assets = modules?.assets ?? [];
|
|
566
|
+
const packagesWeight = weightOf([...(modules?.byId.values() ?? [])].flatMap((row) => row.files), sizes);
|
|
567
|
+
const assetGroups = [
|
|
568
|
+
{ name: 'Loader', meta: 'loads the tiles', kind: 'js' as const },
|
|
569
|
+
{ name: 'Stylesheets', meta: 'of no tile', kind: 'css' as const },
|
|
570
|
+
{ name: 'Images', meta: 'of the page', kind: 'image' as const },
|
|
571
|
+
{ name: 'Fonts', meta: 'its styles declare', kind: 'font' as const },
|
|
572
|
+
{ name: 'Other files', meta: '', kind: 'other' as const },
|
|
573
|
+
].map((group) => ({ ...group, files: assets.filter((file) => file.kind === group.kind) }))
|
|
574
|
+
.filter((group) => group.files.length);
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
<div className="page-modules"
|
|
578
|
+
data-test-id="page-modules"
|
|
579
|
+
data-busy={busy || undefined}>
|
|
580
|
+
<header className="page-modules__head">
|
|
581
|
+
<span className="page-modules__title">Tiles of this page</span>
|
|
582
|
+
<span className="page-modules__status"
|
|
583
|
+
data-test-id="page-modules-status">
|
|
584
|
+
{state.dirty
|
|
585
|
+
? 'saving — the numbers follow the saved draft'
|
|
586
|
+
: busy ? 'reading the page…' : measured
|
|
587
|
+
? `as a reader gets it · ${measured.at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${unread ? ` · ${unread} file(s) could not be read` : ''}`
|
|
588
|
+
: ''}
|
|
589
|
+
</span>
|
|
590
|
+
<button type="button"
|
|
591
|
+
className="page-modules__action"
|
|
592
|
+
data-test-id="page-modules-refresh"
|
|
593
|
+
data-tooltip="Read the page again"
|
|
594
|
+
aria-label="Read the page again"
|
|
595
|
+
onClick={() => {
|
|
596
|
+
// the catalog may have grown (a deploy); the files never change
|
|
597
|
+
catalogs.clear();
|
|
598
|
+
setAsked((n) => n + 1);
|
|
599
|
+
}}>
|
|
600
|
+
↻
|
|
601
|
+
</button>
|
|
602
|
+
<button type="button"
|
|
603
|
+
className="page-modules__action"
|
|
604
|
+
data-test-id="page-modules-close"
|
|
605
|
+
data-tooltip="Close"
|
|
606
|
+
aria-label="Close"
|
|
607
|
+
onClick={() => tool.close()}>
|
|
608
|
+
×
|
|
609
|
+
</button>
|
|
610
|
+
</header>
|
|
611
|
+
<div className="page-modules__body">
|
|
612
|
+
<Rules tool={tool}
|
|
613
|
+
state={state}
|
|
614
|
+
catalog={measured?.catalog ?? []}
|
|
615
|
+
specifiers={specifiers}
|
|
616
|
+
tree={tree} />
|
|
617
|
+
{failure ? <p className="page-modules__error"
|
|
618
|
+
role="alert">{`Could not read the page: ${failure}`}</p> : null}
|
|
619
|
+
<section className="page-modules__section page-modules__section--tree">
|
|
620
|
+
<div className="page-modules__columns"
|
|
621
|
+
data-test-id="page-modules-columns">
|
|
622
|
+
{([['name', 'package · version', ''], ['size', 'size', ' page-modules__bytes'], ['gzip', 'gzip', ' page-modules__gzip']] as const).map(([by, label, cell]) => (
|
|
623
|
+
<button key={by}
|
|
624
|
+
type="button"
|
|
625
|
+
className={`page-modules__column${cell}${order?.by === by ? ' page-modules__column--on' : ''}`}
|
|
626
|
+
data-test-id="page-modules-order"
|
|
627
|
+
data-order={by}
|
|
628
|
+
data-direction={order?.by === by ? (order.descending ? 'down' : 'up') : undefined}
|
|
629
|
+
data-tooltip={by === 'name' ? 'Order by name' : `Order by ${label}`}
|
|
630
|
+
onClick={() => setOrder((was) => nextOrder(was, by))}>
|
|
631
|
+
{label}
|
|
632
|
+
{order?.by === by ? (order.descending ? ' ↓' : ' ↑') : ''}
|
|
633
|
+
</button>
|
|
634
|
+
))}
|
|
635
|
+
</div>
|
|
636
|
+
{/* the packages under one row: folded, it still says what all of them weigh */}
|
|
637
|
+
<TreeRow depth={0}
|
|
638
|
+
open={tree.isOpen('packages')}
|
|
639
|
+
onToggle={() => tree.toggle('packages')}
|
|
640
|
+
name="Packages"
|
|
641
|
+
meta={`${groups.length} package${groups.length === 1 ? '' : 's'} · ${modules?.byId.size ?? 0} tiles`}
|
|
642
|
+
weight={packagesWeight}
|
|
643
|
+
weightTestId="page-modules-packages-weight"
|
|
644
|
+
share={tree.shareOf(packagesWeight)}
|
|
645
|
+
testId="page-modules-packages">
|
|
646
|
+
{groups.map((group) => <GroupNode key={group.key}
|
|
647
|
+
group={group}
|
|
648
|
+
depth={1}
|
|
649
|
+
tree={tree} />)}
|
|
650
|
+
</TreeRow>
|
|
651
|
+
{assetGroups.map((group) => {
|
|
652
|
+
const used = group.files.filter((file) => !file.unused);
|
|
653
|
+
return (
|
|
654
|
+
<FilesNode key={group.name}
|
|
655
|
+
name={group.name}
|
|
656
|
+
files={group.files}
|
|
657
|
+
counted={used}
|
|
658
|
+
tree={tree}
|
|
659
|
+
meta={`${group.meta}${group.meta ? ' · ' : ''}${used.length === group.files.length ? group.files.length : `${used.length} of ${group.files.length} used`}`} />
|
|
660
|
+
);
|
|
661
|
+
})}
|
|
662
|
+
<TreeRow depth={0}
|
|
663
|
+
open={null}
|
|
664
|
+
name="Total"
|
|
665
|
+
meta="the page's files — the HTML aside"
|
|
666
|
+
weight={weightOf(counted, sizes)}
|
|
667
|
+
weightTestId="page-modules-total"
|
|
668
|
+
testId="page-modules-total-row" />
|
|
669
|
+
</section>
|
|
670
|
+
</div>
|
|
671
|
+
<footer className="page-modules__sum"
|
|
672
|
+
data-test-id="page-modules-sum">
|
|
673
|
+
<TreeRow depth={0}
|
|
674
|
+
open={tree.isOpen('first-visit')}
|
|
675
|
+
onToggle={() => tree.toggle('first-visit')}
|
|
676
|
+
name="First visit"
|
|
677
|
+
meta="everything a new visitor fetches"
|
|
678
|
+
weight={first}
|
|
679
|
+
weightTestId="page-modules-first-visit"
|
|
680
|
+
testId="page-modules-visit"
|
|
681
|
+
data={{ 'data-visit': 'first' }}>
|
|
682
|
+
{byKind.map(({ kind, weight, used, declared }) => (
|
|
683
|
+
<TreeRow key={kind}
|
|
684
|
+
depth={1}
|
|
685
|
+
open={null}
|
|
686
|
+
name={KIND_LABELS[kind]}
|
|
687
|
+
meta={used === declared ? `${used} file${used === 1 ? '' : 's'}` : `${used} of ${declared} — the rest the text does not use`}
|
|
688
|
+
weight={weight}
|
|
689
|
+
share={tree.shareOf(weight)}
|
|
690
|
+
testId="page-modules-kind"
|
|
691
|
+
data={{ 'data-kind': kind }} />
|
|
692
|
+
))}
|
|
693
|
+
<TreeRow depth={1}
|
|
694
|
+
open={null}
|
|
695
|
+
name="HTML"
|
|
696
|
+
meta="the page itself"
|
|
697
|
+
weight={html}
|
|
698
|
+
share={tree.shareOf(html)}
|
|
699
|
+
testId="page-modules-kind"
|
|
700
|
+
data={{ 'data-kind': 'html' }} />
|
|
701
|
+
</TreeRow>
|
|
702
|
+
<TreeRow depth={0}
|
|
703
|
+
open={tree.isOpen('repeat-visit')}
|
|
704
|
+
onToggle={() => tree.toggle('repeat-visit')}
|
|
705
|
+
name="Repeat visit"
|
|
706
|
+
meta="the HTML; the rest comes from the browser's cache"
|
|
707
|
+
weight={html}
|
|
708
|
+
weightTestId="page-modules-repeat-visit"
|
|
709
|
+
testId="page-modules-visit"
|
|
710
|
+
data={{ 'data-visit': 'repeat' }}>
|
|
711
|
+
<TreeRow depth={1}
|
|
712
|
+
open={null}
|
|
713
|
+
name="HTML"
|
|
714
|
+
meta="the page itself"
|
|
715
|
+
weight={html} />
|
|
716
|
+
<TreeRow depth={1}
|
|
717
|
+
open={null}
|
|
718
|
+
name={byKind.map(({ kind }) => KIND_LABELS[kind]).join(', ')}
|
|
719
|
+
meta="never change under their address: kept by the browser"
|
|
720
|
+
weight={{ count: 0, bytes: 0, gzip: 0 }} />
|
|
721
|
+
</TreeRow>
|
|
722
|
+
</footer>
|
|
723
|
+
</div>
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function PageModules() {
|
|
728
|
+
const tool = useDiOptional(CANVAS_TOOL_TOKEN);
|
|
729
|
+
if (!tool) {
|
|
730
|
+
return <div className="page-modules page-modules--bare">The tiles of a page show over its canvas: open them from the canvas’s bar.</div>;
|
|
731
|
+
}
|
|
732
|
+
return <Panel tool={tool} />;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export default markInternalTile(defineComponentSide({
|
|
736
|
+
Component: PageModules,
|
|
737
|
+
defaultSettings() {
|
|
738
|
+
return { label: 'Tiles of this page' };
|
|
739
|
+
},
|
|
740
|
+
}));
|