dsh-width-tiers 1.0.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -7
- package/README.zh.md +21 -6
- package/lib/client.js +1100 -1089
- package/lib/index.js +15 -16
- package/package.json +23 -3
package/lib/client.js
CHANGED
|
@@ -4,150 +4,152 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
* The app derives the transcript width from
|
|
13
|
-
* `--dsh-chat-content-width`, which 0.1.7 and 0.2.0 resolve as
|
|
14
|
-
* `var(--dsh-chat-user-width, clamp(680px, column * .64, 920px))` on
|
|
15
|
-
* the conversation body. 0.1.7 also added native drag handles
|
|
16
|
-
* (ConversationWidthControls) that publish
|
|
17
|
-
* `--dsh-conversation-column-width` / `--dsh-chat-user-width` on the
|
|
18
|
-
* body's PARENT and clamp every value to the column minus a 176px
|
|
19
|
-
* handle budget. Forcing `--dsh-chat-content-width` with `!important`
|
|
20
|
-
* (the 1.0.5 approach) muted those handles and let over-wide
|
|
21
|
-
* transcripts overflow under the sidebar/rightbar columns, so this
|
|
22
|
-
* version drives the app's own width axis instead — see
|
|
23
|
-
* `applyContentWidth`. Other invariants:
|
|
24
|
-
*
|
|
25
|
-
* 标准 standard app default (no override)
|
|
26
|
-
* 中等 medium 1120px (sidebar untouched)
|
|
27
|
-
* 宽 wide 1280px (sidebar untouched)
|
|
28
|
-
* 超宽 ultra 1400px (sidebar untouched)
|
|
29
|
-
* 全宽 full column max (sidebar collapsed to the icon rail)
|
|
30
|
-
* 自定义 custom user-dragged width (plugin hands width over to
|
|
31
|
-
* the app's native handles; the dragged value stays
|
|
32
|
-
* re-selectable from the menu). Before the first
|
|
33
|
-
* drag, picking it opens a guide at the right edge
|
|
34
|
-
* and the live column width is shown while dragging.
|
|
35
|
-
*
|
|
36
|
-
* The menu and title show fixed px for medium/wide/ultra
|
|
37
|
-
* (1120/1280/1400), the live app default px for standard, the live
|
|
38
|
-
* maximum (column − 176) for full, and the recorded drag width or
|
|
39
|
-
* "未设置" for custom. The app still clamps every applied px to
|
|
40
|
-
* column − 176. Every handle press (not just the first custom one)
|
|
41
|
-
* opens the live width readout, so repeat drags keep the highlight
|
|
42
|
-
* and the px bubble.
|
|
43
|
-
*
|
|
44
|
-
* The app preference stores the tier's configured target (nominal px,
|
|
45
|
-
* the recorded custom width, or the live cap for full); only the
|
|
46
|
-
* inline value is clamped. This lets the app restore the target when
|
|
47
|
-
* the window widens instead of staying pinned to a narrow clamp.
|
|
48
|
-
* The picker icon has one bar per tier (full lights five, custom all
|
|
49
|
-
* six), tier changes sync across tabs, the menu supports
|
|
50
|
-
* ArrowUp/Down/Home/End/Escape, and the sidebar's embedded
|
|
51
|
-
* Conversation is excluded from the width-root lookup.
|
|
52
|
-
*
|
|
53
|
-
* - Panels are driven through the layout service (ctx.layout), never
|
|
54
|
-
* by simulating clicks.
|
|
55
|
-
* - The sidebar is only ever collapsed by the `full` tier, through the
|
|
56
|
-
* app's own toggle — never force-hidden, never locked. Going back to
|
|
57
|
-
* `standard` restores it only if this plugin collapsed it.
|
|
58
|
-
* - The rightbar is never touched: on 0.1.7+ its panel lives in its
|
|
59
|
-
* own seat, so closing the track would leave it overlaying the
|
|
60
|
-
* conversation; the app's width axis yields space on its own.
|
|
61
|
-
* - The chosen tier persists in localStorage and is restored on the
|
|
62
|
-
* next visit.
|
|
63
|
-
* - All copy is localized through the app's locale service: tier
|
|
64
|
-
* labels and the button's aria/title follow Settings → Language
|
|
65
|
-
* (zh / en, browser-derived fallback) and re-render on switch.
|
|
66
|
-
*/
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
// src/client/constants.ts
|
|
10
|
+
/** LocalStorage key holding the selected tier. */
|
|
67
11
|
const KEY = "dsh.widthTier";
|
|
12
|
+
/** Legacy key from the pre-tier releases; removed on attach. */
|
|
68
13
|
const OLD_KEY = "dsh.wide";
|
|
69
14
|
/** Last user-dragged width (px), backing the `custom` tier. */
|
|
70
15
|
const CUSTOM_KEY = "dsh.widthTierCustom";
|
|
71
16
|
/** The app's own transcript width preference (ConversationWidthControls). */
|
|
72
17
|
const APP_PREF_KEY = "dsh.conversation.contentWidth";
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const NOMINAL_WIDTH_PX = { medium: 1120, wide: 1280, ultra: 1400 };
|
|
79
|
-
/* The app's no-preference formula: clamp(680px, column × 0.64, 920px).
|
|
80
|
-
* Used as the last-resort live readout before the first drag. */
|
|
81
|
-
const DEFAULT_MIN_PX = 680;
|
|
82
|
-
const DEFAULT_MAX_PX = 920;
|
|
83
|
-
const DEFAULT_COLUMN_RATIO = 0.64;
|
|
84
|
-
const READY_TIMEOUT_MS = 30000;
|
|
85
|
-
/* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
|
|
86
|
-
* next animation frame — before paint, so the width never flashes
|
|
87
|
-
* default) with a low-frequency fallback and backoff retries while
|
|
88
|
-
* the width root is missing (conversation-switch remount). */
|
|
89
|
-
const ROOT_RETRY_MIN_MS = 200;
|
|
90
|
-
const ROOT_RETRY_MAX_MS = 2000;
|
|
91
|
-
const FALLBACK_HEAL_MS = 2000;
|
|
92
|
-
|
|
93
|
-
/** Dictionary namespace owned by this plugin. */
|
|
94
|
-
const NS = "width-tiers";
|
|
95
|
-
|
|
96
|
-
//#region lib/types/client/locales.js
|
|
97
|
-
/** `width-tiers` namespace dictionaries (tier labels and picker copy). */
|
|
98
|
-
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
99
|
-
const zh = {
|
|
100
|
-
"tier.standard": "标准",
|
|
101
|
-
"tier.medium": "中等",
|
|
102
|
-
"tier.wide": "宽",
|
|
103
|
-
"tier.ultra": "超宽",
|
|
104
|
-
"tier.full": "全宽",
|
|
105
|
-
"tier.custom": "自定义",
|
|
106
|
-
"value.custom.unset": "未设置",
|
|
107
|
-
"guide.hint": "拖拽右侧边缘调整对话宽度",
|
|
108
|
-
"button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
|
|
109
|
-
"button.title.summary": "对话区宽度档位:{label}({value})",
|
|
110
|
-
"button.title.hint": "点击选择:{list}"
|
|
111
|
-
};
|
|
112
|
-
/** English dictionary, checked complete against the zh key set. */
|
|
113
|
-
const en = {
|
|
114
|
-
"tier.standard": "Standard",
|
|
115
|
-
"tier.medium": "Medium",
|
|
116
|
-
"tier.wide": "Wide",
|
|
117
|
-
"tier.ultra": "Ultra",
|
|
118
|
-
"tier.full": "Full",
|
|
119
|
-
"tier.custom": "Custom",
|
|
120
|
-
"value.custom.unset": "Not set",
|
|
121
|
-
"guide.hint": "Drag the right edge to resize",
|
|
122
|
-
"button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
|
|
123
|
-
"button.title.summary": "Chat width tier: {label} ({value})",
|
|
124
|
-
"button.title.hint": "Click to choose: {list}"
|
|
18
|
+
/** Nominal content widths of the fixed tiers (px). */
|
|
19
|
+
const NOMINAL_WIDTH_PX = {
|
|
20
|
+
medium: 1120,
|
|
21
|
+
wide: 1280,
|
|
22
|
+
ultra: 1400
|
|
125
23
|
};
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
24
|
+
const DEFAULT_COLUMN_RATIO = .64;
|
|
25
|
+
/** How long attach waits before falling back to a slow retry. */
|
|
26
|
+
const READY_TIMEOUT_MS = 3e4;
|
|
27
|
+
const ROOT_RETRY_MAX_MS = 2e3;
|
|
28
|
+
const FALLBACK_HEAL_MS = 2e3;
|
|
29
|
+
/** Attribute the plugin sets on <body> while a tier is active. */
|
|
30
|
+
const TIER_ATTR = "data-dsh-width";
|
|
31
|
+
/** Attribute the plugin sets on <body> while the custom-drag guide is open. */
|
|
32
|
+
const GUIDE_ATTR = "data-dsh-width-guide";
|
|
33
|
+
// src/client/model.ts
|
|
131
34
|
const TIERS = [
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
35
|
+
{
|
|
36
|
+
id: "standard",
|
|
37
|
+
key: "tier.standard"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "medium",
|
|
41
|
+
key: "tier.medium"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "wide",
|
|
45
|
+
key: "tier.wide"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "ultra",
|
|
49
|
+
key: "tier.ultra"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "full",
|
|
53
|
+
key: "tier.full"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "custom",
|
|
57
|
+
key: "tier.custom"
|
|
58
|
+
}
|
|
140
59
|
];
|
|
141
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a tier definition by id, falling back to `standard`.
|
|
62
|
+
* @param id - stored or attributed tier id.
|
|
63
|
+
* @returns the matching definition, or `standard`.
|
|
64
|
+
*/
|
|
142
65
|
function tierOf(id) {
|
|
143
|
-
for (
|
|
144
|
-
if (TIERS[i].id === id) return TIERS[i];
|
|
145
|
-
}
|
|
66
|
+
for (const tier of TIERS) if (tier.id === id) return tier;
|
|
146
67
|
return TIERS[0];
|
|
147
68
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Read a positive persisted number, or null when absent or corrupt.
|
|
71
|
+
* @param storage - backing storage.
|
|
72
|
+
* @param key - storage key.
|
|
73
|
+
* @returns the parsed positive number, or null.
|
|
74
|
+
*/
|
|
75
|
+
function readStoredNumber(storage, key) {
|
|
76
|
+
try {
|
|
77
|
+
const value = Number(storage.getItem(key));
|
|
78
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the saved tier, defaulting to `standard` when absent or unknown.
|
|
85
|
+
* @param storage - backing storage.
|
|
86
|
+
* @returns a valid tier id.
|
|
87
|
+
*/
|
|
88
|
+
function getSavedTier(storage) {
|
|
89
|
+
try {
|
|
90
|
+
const value = storage.getItem(KEY);
|
|
91
|
+
const found = TIERS.find((tier) => tier.id === value);
|
|
92
|
+
return found === void 0 ? "standard" : found.id;
|
|
93
|
+
} catch {
|
|
94
|
+
return "standard";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The tier's configured target, before the app's column clamp. This is the
|
|
99
|
+
* value persisted to the app preference: on a narrow column the app clamps it
|
|
100
|
+
* for display, and when the window widens it restores the configured target
|
|
101
|
+
* instead of staying pinned to the old clamp. `full` is inherently the live
|
|
102
|
+
* maximum; `standard` has no target and delegates to the app default.
|
|
103
|
+
* @param tier - selected tier.
|
|
104
|
+
* @param columnWidth - live width of the app's width column (px).
|
|
105
|
+
* @param customPx - recorded custom width, if any.
|
|
106
|
+
* @returns the target width in px, or null for the app default.
|
|
107
|
+
*/
|
|
108
|
+
function tierTargetPx(tier, columnWidth, customPx) {
|
|
109
|
+
switch (tier) {
|
|
110
|
+
case "medium":
|
|
111
|
+
case "wide":
|
|
112
|
+
case "ultra": return NOMINAL_WIDTH_PX[tier];
|
|
113
|
+
case "full": return fullWidthPx(columnWidth);
|
|
114
|
+
case "custom": return customPx;
|
|
115
|
+
default: return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The px actually applied to the column (same floor/max as the app's
|
|
120
|
+
* `resolveContentWidth`).
|
|
121
|
+
* @param tier - selected tier.
|
|
122
|
+
* @param columnWidth - live width of the app's width column (px).
|
|
123
|
+
* @param customPx - recorded custom width, if any.
|
|
124
|
+
* @returns the clamped width in px, or null for the app default.
|
|
125
|
+
*/
|
|
126
|
+
function appliedTierPx(tier, columnWidth, customPx) {
|
|
127
|
+
const target = tierTargetPx(tier, columnWidth, customPx);
|
|
128
|
+
if (target === null) return null;
|
|
129
|
+
const cap = fullWidthPx(columnWidth);
|
|
130
|
+
return Math.min(Math.max(target, 640), cap);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the app's default transcript width for a column.
|
|
134
|
+
* @param columnWidth - live width of the app's width column (px).
|
|
135
|
+
* @returns `max(680, min(column × 0.64, 920))`.
|
|
136
|
+
*/
|
|
137
|
+
function defaultWidthPx(columnWidth) {
|
|
138
|
+
return Math.max(680, Math.min(columnWidth * DEFAULT_COLUMN_RATIO, 920));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the widest transcript the column can hold (app clamp maximum).
|
|
142
|
+
* @param columnWidth - live width of the app's width column (px).
|
|
143
|
+
* @returns `max(640, column − 176)`.
|
|
144
|
+
*/
|
|
145
|
+
function fullWidthPx(columnWidth) {
|
|
146
|
+
return Math.max(640, columnWidth - 176);
|
|
147
|
+
}
|
|
148
|
+
// src/client/styles.ts
|
|
149
|
+
/**
|
|
150
|
+
* Button + menu styles. Theme variables keep the picker in the app's light /
|
|
151
|
+
* dark palettes; every selector is scoped to this plugin's own ids and classes.
|
|
152
|
+
*/
|
|
151
153
|
const css = [
|
|
152
154
|
"#dsh-wide-toggle{position:fixed;right:10px;bottom:216px;z-index:999;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;overflow:hidden;background:var(--dsw-alias-button-floating-fill);color:var(--dsw-alias-label-secondary);box-shadow:0 2px 8px rgba(0,0,0,.18);cursor:pointer;opacity:.72;transition:opacity .2s ease,width .2s ease,border-radius .2s ease,background .2s ease,color .2s ease}",
|
|
153
155
|
"#dsh-wide-toggle::after{content:'';position:fixed;right:10px;bottom:216px;width:36px;height:36px;pointer-events:auto}",
|
|
@@ -174,985 +176,671 @@ window.__ModuleLoader__.load({
|
|
|
174
176
|
"body[data-dsh-width-guide] [data-width-handle]{background:linear-gradient(to bottom,transparent calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% + 64px),transparent calc(50% + 64px));border-radius:3px}",
|
|
175
177
|
"body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
|
|
176
178
|
].join("");
|
|
177
|
-
//#endregion
|
|
178
|
-
|
|
179
|
-
/** Services required by the browser bundle. */
|
|
180
|
-
const inject = ["layout", "locale"];
|
|
181
|
-
|
|
182
179
|
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* lives in its own seat: closeRightbar() only retracts the
|
|
231
|
-
* grid TRACK (rightbarTrack=false) while the seat stays
|
|
232
|
-
* expanded, so the panel keeps rendering as an OVERLAY pinned
|
|
233
|
-
* to the frame's right edge — hanging over and covering the
|
|
234
|
-
* conversation (对话窗口被边侧栏覆盖). The plugin must never
|
|
235
|
-
* touch the rightbar; the app's own width axis already yields
|
|
236
|
-
* space to the panel. */
|
|
237
|
-
|
|
238
|
-
function collapseSidebar() {
|
|
239
|
-
if (!isSidebarCollapsed()) layout.toggleSidebar();
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function expandSidebar() {
|
|
243
|
-
if (isSidebarCollapsed()) layout.toggleSidebar();
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/* Locate the element that defines --dsh-chat-content-width at
|
|
247
|
-
* runtime (first match wins; only one element declares it).
|
|
248
|
-
* Cached; the app remounts the chat tree on conversation switch,
|
|
249
|
-
* so re-location first walks the recorded child-index path from
|
|
250
|
-
* <body> (React keeps the tree shape — O(depth), one style
|
|
251
|
-
* read) and only falls back to a full scan. */
|
|
252
|
-
let widthRoot = null;
|
|
253
|
-
let widthPath = null;
|
|
254
|
-
|
|
255
|
-
/* Whether this plugin wrote the app's width preference. Native
|
|
256
|
-
* drags also write it, so unload only removes a value this
|
|
257
|
-
* plugin owns. */
|
|
258
|
-
let preferenceOwned = false;
|
|
259
|
-
let preferenceWritten = null;
|
|
260
|
-
let pulseTimer = null;
|
|
261
|
-
|
|
262
|
-
function hasWidthVar(el) {
|
|
263
|
-
return getComputedStyle(el)
|
|
264
|
-
.getPropertyValue("--dsh-chat-content-width")
|
|
265
|
-
.trim() !== "";
|
|
180
|
+
* Install the plugin stylesheet and return its removal disposer.
|
|
181
|
+
* @returns removes the owned style element from the document head.
|
|
182
|
+
*/
|
|
183
|
+
function installStyles() {
|
|
184
|
+
const styleTag = document.createElement("style");
|
|
185
|
+
styleTag.dataset.plugin = "dsh-width-tiers";
|
|
186
|
+
styleTag.textContent = css;
|
|
187
|
+
document.head.appendChild(styleTag);
|
|
188
|
+
return () => {
|
|
189
|
+
if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// src/client/width-root.ts
|
|
193
|
+
/** Whether an element resolves a non-empty `--dsh-chat-content-width`. */
|
|
194
|
+
function hasWidthVar(el) {
|
|
195
|
+
return getComputedStyle(el).getPropertyValue("--dsh-chat-content-width").trim() !== "";
|
|
196
|
+
}
|
|
197
|
+
function isSidebarConversation(el) {
|
|
198
|
+
return typeof el.closest === "function" && el.closest("[data-sidebar-chat]") !== null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The element the app's ConversationWidthControls publishes
|
|
202
|
+
* `--dsh-conversation-column-width` / `--dsh-chat-user-width` on.
|
|
203
|
+
* @param root - element that declares `--dsh-chat-content-width`.
|
|
204
|
+
* @returns the parent element, or the root itself when detached.
|
|
205
|
+
*/
|
|
206
|
+
function widthTarget(root) {
|
|
207
|
+
return root.parentElement ?? root;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Create the runtime locator. Cached; the app remounts the chat tree on
|
|
211
|
+
* conversation switch, so re-location first walks the recorded child-index
|
|
212
|
+
* path from <body> (React keeps the tree shape — O(depth), one style read)
|
|
213
|
+
* and only falls back to a full scan.
|
|
214
|
+
* @returns locator bound to this page's document.
|
|
215
|
+
*/
|
|
216
|
+
function createWidthRootLocator() {
|
|
217
|
+
let widthRoot = null;
|
|
218
|
+
let widthPath = null;
|
|
219
|
+
function recordWidthPath(el) {
|
|
220
|
+
const path = [];
|
|
221
|
+
let node = el;
|
|
222
|
+
while (node && node !== document.body) {
|
|
223
|
+
const parent = node.parentNode;
|
|
224
|
+
if (!parent || !parent.children) break;
|
|
225
|
+
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
|
226
|
+
node = parent;
|
|
266
227
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
|
275
|
-
node = parent;
|
|
276
|
-
}
|
|
277
|
-
widthPath = path.length > 0 ? path : null;
|
|
228
|
+
widthPath = path.length > 0 ? path : null;
|
|
229
|
+
}
|
|
230
|
+
function firstWidthRoot(list) {
|
|
231
|
+
for (let i = 0; i < list.length; i++) {
|
|
232
|
+
const el = list[i];
|
|
233
|
+
if (el === void 0 || isSidebarConversation(el)) continue;
|
|
234
|
+
if (hasWidthVar(el)) return el;
|
|
278
235
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function scanWidthRoot() {
|
|
239
|
+
let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
|
|
240
|
+
if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
|
|
241
|
+
if (found !== null) {
|
|
242
|
+
widthRoot = found;
|
|
243
|
+
recordWidthPath(widthRoot);
|
|
244
|
+
return widthRoot;
|
|
287
245
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
246
|
+
widthRoot = null;
|
|
247
|
+
widthPath = null;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
function find(allowScan = true) {
|
|
251
|
+
if (widthRoot !== null && document.contains(widthRoot) && !isSidebarConversation(widthRoot)) return widthRoot;
|
|
252
|
+
if (widthPath !== null) {
|
|
253
|
+
let el = document.body;
|
|
254
|
+
for (let i = 0; i < widthPath.length; i++) {
|
|
255
|
+
if (el === null) break;
|
|
256
|
+
const next = el.children[widthPath[i]];
|
|
257
|
+
if (!next) {
|
|
258
|
+
el = null;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
el = next;
|
|
294
262
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
function scanWidthRoot() {
|
|
299
|
-
/* Prefer the stable Conversation data attribute (one or two
|
|
300
|
-
* elements) over every classed node in the document. The
|
|
301
|
-
* [class] walk remains the compatibility fallback for older
|
|
302
|
-
* clients that do not publish data-conversation-content. */
|
|
303
|
-
let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
|
|
304
|
-
if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
|
|
305
|
-
if (found !== null) {
|
|
306
|
-
widthRoot = found;
|
|
307
|
-
recordWidthPath(widthRoot);
|
|
263
|
+
if (el !== null && el !== widthRoot && document.contains(el) && !isSidebarConversation(el) && hasWidthVar(el)) {
|
|
264
|
+
widthRoot = el;
|
|
308
265
|
return widthRoot;
|
|
309
266
|
}
|
|
267
|
+
}
|
|
268
|
+
if (!allowScan) return null;
|
|
269
|
+
return scanWidthRoot();
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
find,
|
|
273
|
+
cached: () => widthRoot,
|
|
274
|
+
reset: () => {
|
|
310
275
|
widthRoot = null;
|
|
311
276
|
widthPath = null;
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function findWidthRoot(allowScan) {
|
|
316
|
-
if (widthRoot !== null && document.contains(widthRoot)
|
|
317
|
-
&& !isSidebarConversation(widthRoot)) return widthRoot;
|
|
318
|
-
if (widthPath !== null) {
|
|
319
|
-
let el = document.body;
|
|
320
|
-
for (let i = 0; i < widthPath.length; i++) {
|
|
321
|
-
el = el.children[widthPath[i]];
|
|
322
|
-
if (!el) break;
|
|
323
|
-
}
|
|
324
|
-
if (el && el !== widthRoot && document.contains(el)
|
|
325
|
-
&& !isSidebarConversation(el) && hasWidthVar(el)) {
|
|
326
|
-
widthRoot = el;
|
|
327
|
-
return widthRoot;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
if (allowScan === false) return null;
|
|
331
|
-
return scanWidthRoot();
|
|
332
277
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function liveWidthPx(root) {
|
|
281
|
+
const target = widthTarget(root);
|
|
282
|
+
const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
|
|
283
|
+
if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
|
|
284
|
+
const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
|
|
285
|
+
const computed = Number.parseFloat(raw);
|
|
286
|
+
if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
|
|
287
|
+
return Math.round(defaultWidthPx(root.offsetWidth));
|
|
288
|
+
}
|
|
289
|
+
// src/client/controller.ts
|
|
290
|
+
/** Estimated bubble width used when placing the guide beside a drag handle. */
|
|
291
|
+
const GUIDE_WIDTH_PX = 180;
|
|
292
|
+
/** How long the settled width stays visible after a drag. */
|
|
293
|
+
const GUIDE_HIDE_MS = 800;
|
|
294
|
+
/** Default guide placement (frame edge, vertical center). */
|
|
295
|
+
const GUIDE_FALLBACK = {
|
|
296
|
+
left: "auto",
|
|
297
|
+
right: "24px",
|
|
298
|
+
top: "50%"
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* Own the width-tier model: the app's native width axis, the tier/guide state
|
|
302
|
+
* machine and persistence. Rendering lives in the React picker, which reads
|
|
303
|
+
* `source` and dispatches `actions`; this controller never touches the picker
|
|
304
|
+
* DOM, so the UI can remount without rebuilding observers.
|
|
305
|
+
* @param options - layout service, locale subscription and translate function.
|
|
306
|
+
* @returns observable state, actions and the disposer.
|
|
307
|
+
*/
|
|
308
|
+
function createWidthTiers(options) {
|
|
309
|
+
const { layout, t } = options;
|
|
310
|
+
const roots = createWidthRootLocator();
|
|
311
|
+
const removeStyles = installStyles();
|
|
312
|
+
const body = () => document.body;
|
|
313
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
314
|
+
let snapshot;
|
|
315
|
+
let menuOpen = false;
|
|
316
|
+
let pulseSeq = 0;
|
|
317
|
+
let guideActive = false;
|
|
318
|
+
let guideDragging = false;
|
|
319
|
+
let guideFromMenu = false;
|
|
320
|
+
let guideMode = "hint";
|
|
321
|
+
let guideText = "";
|
|
322
|
+
let guideStyle = GUIDE_FALLBACK;
|
|
323
|
+
let guideHandle = null;
|
|
324
|
+
let guideHideTimer = null;
|
|
325
|
+
let guideWidth = GUIDE_WIDTH_PX;
|
|
326
|
+
let tierBeforeGuide = "standard";
|
|
327
|
+
let preferenceOwned = false;
|
|
328
|
+
let preferenceWritten = null;
|
|
329
|
+
let collapsedByTier = false;
|
|
330
|
+
function currentTier() {
|
|
331
|
+
const value = body().getAttribute(TIER_ATTR);
|
|
332
|
+
const found = value === null ? void 0 : TIERS.find((tier) => tier.id === value);
|
|
333
|
+
return found === void 0 ? "standard" : found.id;
|
|
334
|
+
}
|
|
335
|
+
function getSavedTier$1() {
|
|
336
|
+
try {
|
|
337
|
+
return getSavedTier(window.localStorage);
|
|
338
|
+
} catch {
|
|
339
|
+
return "standard";
|
|
380
340
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
* property (which may stay as a var()/clamp() token stream). */
|
|
388
|
-
function liveWidthPx(root) {
|
|
389
|
-
const target = widthTarget(root);
|
|
390
|
-
const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
|
|
391
|
-
if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
|
|
392
|
-
const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
|
|
393
|
-
const computed = Number.parseFloat(raw);
|
|
394
|
-
if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
|
|
395
|
-
return Math.round(Math.max(
|
|
396
|
-
DEFAULT_MIN_PX,
|
|
397
|
-
Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
|
|
398
|
-
));
|
|
341
|
+
}
|
|
342
|
+
function readNumber(key) {
|
|
343
|
+
try {
|
|
344
|
+
return readStoredNumber(window.localStorage, key);
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
399
347
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
window.localStorage.removeItem(APP_PREF_KEY);
|
|
417
|
-
} catch (e) {
|
|
418
|
-
/* ignore */
|
|
419
|
-
}
|
|
420
|
-
preferenceOwned = false;
|
|
421
|
-
preferenceWritten = null;
|
|
422
|
-
}
|
|
423
|
-
return;
|
|
348
|
+
}
|
|
349
|
+
function isSidebarCollapsed() {
|
|
350
|
+
return document.querySelector("[data-sidebar-collapsed]") !== null;
|
|
351
|
+
}
|
|
352
|
+
function collapseSidebar() {
|
|
353
|
+
if (!isSidebarCollapsed()) layout.toggleSidebar();
|
|
354
|
+
}
|
|
355
|
+
function expandSidebar() {
|
|
356
|
+
if (isSidebarCollapsed()) layout.toggleSidebar();
|
|
357
|
+
}
|
|
358
|
+
function tierValueText(tierId) {
|
|
359
|
+
switch (tierId) {
|
|
360
|
+
case "standard": {
|
|
361
|
+
const root = roots.find();
|
|
362
|
+
if (root === null) return "—";
|
|
363
|
+
return `${Math.round(defaultWidthPx(root.offsetWidth))}px`;
|
|
424
364
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (px !== null) {
|
|
433
|
-
const next = String(Math.round(px));
|
|
434
|
-
if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
|
|
435
|
-
target.style.setProperty("--dsh-chat-user-width", next + "px");
|
|
436
|
-
}
|
|
437
|
-
}
|
|
365
|
+
case "medium":
|
|
366
|
+
case "wide":
|
|
367
|
+
case "ultra": return `${NOMINAL_WIDTH_PX[tierId]}px`;
|
|
368
|
+
case "full": {
|
|
369
|
+
const root = roots.find();
|
|
370
|
+
if (root === null) return "—";
|
|
371
|
+
return `${Math.round(fullWidthPx(root.offsetWidth))}px`;
|
|
438
372
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
* restores it after a resize / remount. */
|
|
443
|
-
const preference = String(Math.round(targetPx));
|
|
444
|
-
try {
|
|
445
|
-
if (window.localStorage.getItem(APP_PREF_KEY) !== preference) {
|
|
446
|
-
window.localStorage.setItem(APP_PREF_KEY, preference);
|
|
447
|
-
preferenceOwned = true;
|
|
448
|
-
preferenceWritten = preference;
|
|
449
|
-
}
|
|
450
|
-
} catch (e) {
|
|
451
|
-
/* ignore */
|
|
373
|
+
case "custom": {
|
|
374
|
+
const dragged = readNumber(CUSTOM_KEY);
|
|
375
|
+
return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
|
|
452
376
|
}
|
|
377
|
+
default: return "—";
|
|
453
378
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
379
|
+
}
|
|
380
|
+
function guideValueText() {
|
|
381
|
+
const root = roots.find();
|
|
382
|
+
const px = root === null ? null : liveWidthPx(root);
|
|
383
|
+
return px === null ? "—" : `${px}px`;
|
|
384
|
+
}
|
|
385
|
+
function buildSnapshot() {
|
|
386
|
+
const tier = currentTier();
|
|
387
|
+
const def = tierOf(tier);
|
|
388
|
+
const label = t(def.key);
|
|
389
|
+
const value = tierValueText(tier);
|
|
390
|
+
const summary = t("button.title.summary", {
|
|
391
|
+
label,
|
|
392
|
+
value
|
|
393
|
+
});
|
|
394
|
+
const hint = t("button.title.hint", { list: TIERS.map((tierDef) => `${t(tierDef.key)} ${tierValueText(tierDef.id)}`).join(" / ") });
|
|
395
|
+
return {
|
|
396
|
+
tier,
|
|
397
|
+
menuOpen,
|
|
398
|
+
pulse: pulseSeq,
|
|
399
|
+
button: {
|
|
400
|
+
active: tier !== "standard",
|
|
401
|
+
label,
|
|
402
|
+
value,
|
|
403
|
+
ariaLabel: t("button.aria", {
|
|
404
|
+
label,
|
|
405
|
+
value
|
|
406
|
+
}),
|
|
407
|
+
title: `${summary}\n${hint}`
|
|
408
|
+
},
|
|
409
|
+
items: TIERS.map((tierDef) => ({
|
|
410
|
+
id: tierDef.id,
|
|
411
|
+
label: t(tierDef.key),
|
|
412
|
+
value: tierValueText(tierDef.id),
|
|
413
|
+
selected: tierDef.id === tier
|
|
414
|
+
})),
|
|
415
|
+
guide: {
|
|
416
|
+
active: guideActive,
|
|
417
|
+
dragging: guideDragging,
|
|
418
|
+
mode: guideMode,
|
|
419
|
+
text: guideText,
|
|
420
|
+
style: guideStyle
|
|
468
421
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function emit() {
|
|
425
|
+
snapshot = buildSnapshot();
|
|
426
|
+
for (const listener of listeners) listener();
|
|
427
|
+
}
|
|
428
|
+
function applyContentWidth(tier) {
|
|
429
|
+
const root = roots.find();
|
|
430
|
+
if (root === null) return;
|
|
431
|
+
const target = widthTarget(root);
|
|
432
|
+
if (tier === "custom" && readNumber("dsh.widthTierCustom") === null) return;
|
|
433
|
+
const targetPx = tierTargetPx(tier, root.offsetWidth, readNumber(CUSTOM_KEY));
|
|
434
|
+
if (targetPx === null) {
|
|
435
|
+
if (target.style.getPropertyValue("--dsh-chat-user-width") !== "" || preferenceOwned) {
|
|
436
|
+
target.style.removeProperty("--dsh-chat-user-width");
|
|
437
|
+
try {
|
|
438
|
+
window.localStorage.removeItem(APP_PREF_KEY);
|
|
439
|
+
} catch {}
|
|
440
|
+
preferenceOwned = false;
|
|
441
|
+
preferenceWritten = null;
|
|
483
442
|
}
|
|
484
|
-
|
|
485
|
-
syncMenu();
|
|
486
|
-
pulse();
|
|
443
|
+
return;
|
|
487
444
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
function barIcon(tier) {
|
|
497
|
-
let idx = 0;
|
|
498
|
-
for (let i = 0; i < TIERS.length; i++) {
|
|
499
|
-
if (TIERS[i].id === tier) idx = i;
|
|
500
|
-
}
|
|
501
|
-
/* One bar per tier so full (5 bars) and custom (6 bars,
|
|
502
|
-
* all lit) remain distinguishable. */
|
|
503
|
-
const widths = [3.5, 5.3, 7.1, 8.9, 10.7, 12.5];
|
|
504
|
-
let bars = "";
|
|
505
|
-
for (let b = 0; b < TIERS.length; b++) {
|
|
506
|
-
const y = 0.75 + b * 2.55;
|
|
507
|
-
bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="1.6" rx="0.8" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
|
|
445
|
+
const laidOut = root.offsetWidth > 0;
|
|
446
|
+
if (!laidOut && tier === "full") return;
|
|
447
|
+
if (laidOut) {
|
|
448
|
+
const px = appliedTierPx(tier, root.offsetWidth, readNumber(CUSTOM_KEY));
|
|
449
|
+
if (px !== null) {
|
|
450
|
+
const next = String(Math.round(px));
|
|
451
|
+
if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") target.style.setProperty("--dsh-chat-user-width", next + "px");
|
|
508
452
|
}
|
|
509
|
-
return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
|
|
510
453
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
switch (tierId) {
|
|
518
|
-
case "standard": {
|
|
519
|
-
const root = findWidthRoot();
|
|
520
|
-
if (root === null) return "—";
|
|
521
|
-
return `${Math.round(Math.max(
|
|
522
|
-
DEFAULT_MIN_PX,
|
|
523
|
-
Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
|
|
524
|
-
))}px`;
|
|
525
|
-
}
|
|
526
|
-
case "medium":
|
|
527
|
-
case "wide":
|
|
528
|
-
case "ultra":
|
|
529
|
-
return `${NOMINAL_WIDTH_PX[tierId]}px`;
|
|
530
|
-
case "full": {
|
|
531
|
-
const root = findWidthRoot();
|
|
532
|
-
if (root === null) return "—";
|
|
533
|
-
return `${Math.round(Math.max(
|
|
534
|
-
CONTENT_MIN_PX,
|
|
535
|
-
root.offsetWidth - CONTENT_EDGE_BUDGET_PX
|
|
536
|
-
))}px`;
|
|
537
|
-
}
|
|
538
|
-
case "custom": {
|
|
539
|
-
const dragged = readStoredNumber(CUSTOM_KEY);
|
|
540
|
-
return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
|
|
541
|
-
}
|
|
542
|
-
default: return "—";
|
|
454
|
+
const preference = String(Math.round(targetPx));
|
|
455
|
+
try {
|
|
456
|
+
if (window.localStorage.getItem("dsh.conversation.contentWidth") !== preference) {
|
|
457
|
+
window.localStorage.setItem(APP_PREF_KEY, preference);
|
|
458
|
+
preferenceOwned = true;
|
|
459
|
+
preferenceWritten = preference;
|
|
543
460
|
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
btn.classList.toggle("active", tier !== "standard");
|
|
557
|
-
btn.setAttribute("aria-label", t("button.aria", { label, value }));
|
|
558
|
-
btn.title = `${summary}\n${hint}`;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/* ---- tier menu ---- */
|
|
562
|
-
const menu = document.createElement("div");
|
|
563
|
-
menu.id = "dsh-width-menu";
|
|
564
|
-
menu.setAttribute("role", "menu");
|
|
565
|
-
menu.hidden = true;
|
|
566
|
-
|
|
567
|
-
for (const def of TIERS) {
|
|
568
|
-
const item = document.createElement("button");
|
|
569
|
-
item.type = "button";
|
|
570
|
-
item.setAttribute("role", "menuitemradio");
|
|
571
|
-
item.setAttribute("data-tier", def.id);
|
|
572
|
-
/* Roving focus: the menu is opened by focusing one item, and
|
|
573
|
-
* ArrowUp/Down/Home/End move between items. */
|
|
574
|
-
item.tabIndex = -1;
|
|
575
|
-
item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
|
|
576
|
-
item.addEventListener("click", (e) => {
|
|
577
|
-
if (e && e.stopPropagation) e.stopPropagation();
|
|
578
|
-
/* Custom before the first drag has no width of its own:
|
|
579
|
-
* open the right-edge guide instead of applying a value. */
|
|
580
|
-
if (def.id === "custom" && readStoredNumber(CUSTOM_KEY) === null) {
|
|
581
|
-
beginCustomGuide();
|
|
582
|
-
hideMenu(true);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
endGuide(false);
|
|
586
|
-
applyTier(def.id);
|
|
587
|
-
hideMenu(true);
|
|
588
|
-
});
|
|
589
|
-
menu.appendChild(item);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/* Write only on change: syncMenu also runs on window resizes,
|
|
593
|
-
* and untouched text should not churn the DOM (the plugin's own
|
|
594
|
-
* MutationObserver watches childList). */
|
|
595
|
-
function setText(el, text) {
|
|
596
|
-
if (el.textContent !== text) el.textContent = text;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function syncMenu() {
|
|
600
|
-
const tier = currentTier();
|
|
601
|
-
const items = menu.querySelectorAll("button[data-tier]");
|
|
602
|
-
for (let i = 0; i < items.length; i++) {
|
|
603
|
-
const id = items[i].getAttribute("data-tier");
|
|
604
|
-
const on = id === tier;
|
|
605
|
-
items[i].classList.toggle("selected", on);
|
|
606
|
-
items[i].setAttribute("aria-checked", String(on));
|
|
607
|
-
const labelEl = items[i].querySelector(".dshwm-label");
|
|
608
|
-
if (labelEl) setText(labelEl, t(tierOf(id).key));
|
|
609
|
-
const valueEl = items[i].querySelector(".dshwm-value");
|
|
610
|
-
if (valueEl) setText(valueEl, tierValueText(id));
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function menuItems() {
|
|
615
|
-
return Array.from(menu.querySelectorAll("button[data-tier]"));
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function showMenu() {
|
|
619
|
-
menu.hidden = false;
|
|
620
|
-
btn.setAttribute("aria-expanded", "true");
|
|
621
|
-
syncMenu();
|
|
622
|
-
const items = menuItems();
|
|
623
|
-
const selected = items.find((item) => item.getAttribute("data-tier") === currentTier()) || items[0];
|
|
624
|
-
if (selected && typeof selected.focus === "function") selected.focus();
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/* restoreFocus is true for Escape / item selection, false for a
|
|
628
|
-
* click-away so the click target keeps focus. */
|
|
629
|
-
function hideMenu(restoreFocus) {
|
|
630
|
-
const hadFocus = menu.contains(document.activeElement);
|
|
631
|
-
menu.hidden = true;
|
|
632
|
-
btn.setAttribute("aria-expanded", "false");
|
|
633
|
-
if (restoreFocus && hadFocus && typeof btn.focus === "function") btn.focus();
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
btn.addEventListener("click", (e) => {
|
|
637
|
-
if (e && e.stopPropagation) e.stopPropagation();
|
|
638
|
-
if (menu.hidden) showMenu();
|
|
639
|
-
else hideMenu(true);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
const onDocClick = (e) => {
|
|
643
|
-
if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu(false);
|
|
644
|
-
/* Clicking anywhere but the guide's handle cancels a
|
|
645
|
-
* pending custom guide and restores the previous tier. */
|
|
646
|
-
if (guideActive && !guideDragging && e.target !== btn && !menu.contains(e.target) && !isHandleEvent(e)) {
|
|
647
|
-
endGuide(true);
|
|
461
|
+
} catch {}
|
|
462
|
+
}
|
|
463
|
+
function applyTier(tier, persist = true) {
|
|
464
|
+
body().setAttribute(TIER_ATTR, tier);
|
|
465
|
+
if (persist) try {
|
|
466
|
+
window.localStorage.setItem(KEY, tier);
|
|
467
|
+
} catch {}
|
|
468
|
+
applyContentWidth(tier);
|
|
469
|
+
if (tier === "full") {
|
|
470
|
+
if (!isSidebarCollapsed()) {
|
|
471
|
+
collapseSidebar();
|
|
472
|
+
collapsedByTier = true;
|
|
648
473
|
}
|
|
474
|
+
} else if (tier === "standard") {
|
|
475
|
+
if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
|
|
476
|
+
collapsedByTier = false;
|
|
477
|
+
} else if (!isSidebarCollapsed()) collapsedByTier = false;
|
|
478
|
+
pulseSeq += 1;
|
|
479
|
+
emit();
|
|
480
|
+
}
|
|
481
|
+
function computeGuideStyle() {
|
|
482
|
+
const handle = guideHandle !== null && document.contains(guideHandle) ? guideHandle : document.querySelector("[data-width-handle=\"right\"]");
|
|
483
|
+
const viewport = window.innerWidth || 0;
|
|
484
|
+
if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) return GUIDE_FALLBACK;
|
|
485
|
+
const rect = handle.getBoundingClientRect();
|
|
486
|
+
const gap = 10;
|
|
487
|
+
const top = `${rect.top + rect.height / 2}px`;
|
|
488
|
+
if (viewport - rect.right >= guideWidth + gap) return {
|
|
489
|
+
left: `${rect.right + gap}px`,
|
|
490
|
+
right: "auto",
|
|
491
|
+
top
|
|
649
492
|
};
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
if (menu.hidden) return;
|
|
657
|
-
if (e.key === "Tab") {
|
|
658
|
-
/* Let Tab move on, but do not leave an orphaned menu. */
|
|
659
|
-
hideMenu(false);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
const items = menuItems();
|
|
663
|
-
if (items.length === 0) return;
|
|
664
|
-
const current = items.indexOf(document.activeElement);
|
|
665
|
-
let next = -1;
|
|
666
|
-
if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
|
|
667
|
-
else if (e.key === "ArrowUp") next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
|
|
668
|
-
else if (e.key === "Home") next = 0;
|
|
669
|
-
else if (e.key === "End") next = items.length - 1;
|
|
670
|
-
if (next < 0) return;
|
|
671
|
-
e.preventDefault();
|
|
672
|
-
const item = items[next];
|
|
673
|
-
if (typeof item.focus === "function") item.focus();
|
|
493
|
+
return {
|
|
494
|
+
left: `${Math.max(8, rect.left - gap - guideWidth)}px`,
|
|
495
|
+
right: "auto",
|
|
496
|
+
top
|
|
674
497
|
};
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
let guideDragging = false;
|
|
697
|
-
/* true while the bubble came from picking custom in the menu (its
|
|
698
|
-
* cancel path restores the previous tier). Direct handle drags
|
|
699
|
-
* open the same bubble but never "cancel" a tier. */
|
|
700
|
-
let guideFromMenu = false;
|
|
701
|
-
let guideHideTimer = null;
|
|
702
|
-
let guideHandle = null;
|
|
703
|
-
let tierBeforeGuide = "standard";
|
|
704
|
-
|
|
705
|
-
/* The px the app currently resolves for the transcript, after
|
|
706
|
-
* its own clamp — what the guide's live readout shows. */
|
|
707
|
-
function guideValueText() {
|
|
708
|
-
const root = findWidthRoot();
|
|
709
|
-
const px = root === null ? null : liveWidthPx(root);
|
|
710
|
-
return px === null ? "—" : `${px}px`;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/* Keep the bubble beside the handle the user actually grabbed
|
|
714
|
-
* (it moves with the dragged edge); fall back to the right
|
|
715
|
-
* handle / frame edge when the handle is not mounted yet
|
|
716
|
-
* (menu-opened guide, hero or settling phase). */
|
|
717
|
-
function positionGuide() {
|
|
718
|
-
if (!guideActive) return;
|
|
719
|
-
const handle = guideHandle !== null && document.contains(guideHandle)
|
|
720
|
-
? guideHandle
|
|
721
|
-
: document.querySelector('[data-width-handle="right"]');
|
|
722
|
-
const viewport = window.innerWidth || 0;
|
|
723
|
-
if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) {
|
|
724
|
-
guide.style.left = "auto";
|
|
725
|
-
guide.style.right = "24px";
|
|
726
|
-
guide.style.top = "50%";
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
const rect = handle.getBoundingClientRect();
|
|
730
|
-
const gap = 10;
|
|
731
|
-
const width = guide.offsetWidth || 180;
|
|
732
|
-
guide.style.top = `${rect.top + rect.height / 2}px`;
|
|
733
|
-
guide.style.right = "auto";
|
|
734
|
-
if (viewport - rect.right >= width + gap) {
|
|
735
|
-
guide.style.left = `${rect.right + gap}px`;
|
|
736
|
-
} else {
|
|
737
|
-
guide.style.left = `${Math.max(8, rect.left - gap - width)}px`;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function setGuideText(mode, text) {
|
|
742
|
-
guide.dataset.mode = mode;
|
|
743
|
-
setText(guideTextEl, text);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function startGuide() {
|
|
747
|
-
guideFromMenu = true;
|
|
748
|
-
guideActive = true;
|
|
749
|
-
guideDragging = false;
|
|
750
|
-
guideHandle = null;
|
|
751
|
-
guide.hidden = false;
|
|
752
|
-
body().setAttribute("data-dsh-width-guide", "custom");
|
|
753
|
-
setGuideText("hint", t("guide.hint"));
|
|
754
|
-
positionGuide();
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
/* Every handle press shows the live readout, not just the first
|
|
758
|
-
* guided drag — a repeat custom drag must keep the handle
|
|
759
|
-
* highlight and the width bubble. */
|
|
760
|
-
function startLiveGuide(handle) {
|
|
498
|
+
}
|
|
499
|
+
function updateGuideStyle() {
|
|
500
|
+
if (!guideActive) return;
|
|
501
|
+
const next = computeGuideStyle();
|
|
502
|
+
if (next.left === guideStyle.left && next.right === guideStyle.right && next.top === guideStyle.top) return;
|
|
503
|
+
guideStyle = next;
|
|
504
|
+
emit();
|
|
505
|
+
}
|
|
506
|
+
function startGuide() {
|
|
507
|
+
guideFromMenu = true;
|
|
508
|
+
guideActive = true;
|
|
509
|
+
guideDragging = false;
|
|
510
|
+
guideHandle = null;
|
|
511
|
+
body().setAttribute(GUIDE_ATTR, "custom");
|
|
512
|
+
guideMode = "hint";
|
|
513
|
+
guideText = t("guide.hint");
|
|
514
|
+
guideStyle = computeGuideStyle();
|
|
515
|
+
emit();
|
|
516
|
+
}
|
|
517
|
+
function startLiveGuide(handle) {
|
|
518
|
+
if (guideHideTimer !== null) {
|
|
761
519
|
window.clearTimeout(guideHideTimer);
|
|
762
|
-
|
|
763
|
-
guideDragging = true;
|
|
764
|
-
guideHandle = handle || null;
|
|
765
|
-
guide.hidden = false;
|
|
766
|
-
body().setAttribute("data-dsh-width-guide", "custom");
|
|
767
|
-
setGuideText("live", guideValueText());
|
|
768
|
-
positionGuide();
|
|
520
|
+
guideHideTimer = null;
|
|
769
521
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
522
|
+
guideActive = true;
|
|
523
|
+
guideDragging = true;
|
|
524
|
+
guideHandle = handle ?? null;
|
|
525
|
+
body().setAttribute(GUIDE_ATTR, "custom");
|
|
526
|
+
guideMode = "live";
|
|
527
|
+
guideText = guideValueText();
|
|
528
|
+
guideStyle = computeGuideStyle();
|
|
529
|
+
emit();
|
|
530
|
+
}
|
|
531
|
+
function beginCustomGuide() {
|
|
532
|
+
if (!guideActive) {
|
|
533
|
+
tierBeforeGuide = currentTier();
|
|
534
|
+
applyTier("custom");
|
|
780
535
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
536
|
+
startGuide();
|
|
537
|
+
}
|
|
538
|
+
function finishGuide() {
|
|
539
|
+
if (!guideActive) return;
|
|
540
|
+
guideDragging = false;
|
|
541
|
+
guideMode = "live";
|
|
542
|
+
guideText = guideValueText();
|
|
543
|
+
if (guideHideTimer !== null) window.clearTimeout(guideHideTimer);
|
|
544
|
+
guideHideTimer = window.setTimeout(() => endGuide(false), GUIDE_HIDE_MS);
|
|
545
|
+
emit();
|
|
546
|
+
}
|
|
547
|
+
function endGuide(restoreTier) {
|
|
548
|
+
if (!guideActive) return;
|
|
549
|
+
guideActive = false;
|
|
550
|
+
guideDragging = false;
|
|
551
|
+
if (guideHideTimer !== null) {
|
|
787
552
|
window.clearTimeout(guideHideTimer);
|
|
788
|
-
guideHideTimer =
|
|
553
|
+
guideHideTimer = null;
|
|
789
554
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
555
|
+
guideMode = "hint";
|
|
556
|
+
guideHandle = null;
|
|
557
|
+
guideWidth = GUIDE_WIDTH_PX;
|
|
558
|
+
guideStyle = GUIDE_FALLBACK;
|
|
559
|
+
body().removeAttribute(GUIDE_ATTR);
|
|
560
|
+
const restore = restoreTier && guideFromMenu && readNumber("dsh.widthTierCustom") === null;
|
|
561
|
+
guideFromMenu = false;
|
|
562
|
+
if (restore) applyTier(tierBeforeGuide);
|
|
563
|
+
else emit();
|
|
564
|
+
}
|
|
565
|
+
const onGuidePointerMove = () => {
|
|
566
|
+
if (!guideActive || !guideDragging) return;
|
|
567
|
+
guideText = guideValueText();
|
|
568
|
+
guideStyle = computeGuideStyle();
|
|
569
|
+
emit();
|
|
570
|
+
};
|
|
571
|
+
document.addEventListener("pointermove", onGuidePointerMove, true);
|
|
572
|
+
const isHandleEvent = (e) => e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
|
|
573
|
+
const onHandlePointerDown = (e) => {
|
|
574
|
+
if (!isHandleEvent(e)) return;
|
|
575
|
+
const target = e.target;
|
|
576
|
+
startLiveGuide(target.closest("[data-width-handle]"));
|
|
577
|
+
if (currentTier() === "custom") return;
|
|
578
|
+
body().setAttribute(TIER_ATTR, "custom");
|
|
579
|
+
try {
|
|
580
|
+
window.localStorage.setItem(KEY, "custom");
|
|
581
|
+
} catch {}
|
|
582
|
+
emit();
|
|
583
|
+
};
|
|
584
|
+
const onHandlePointerUp = (e) => {
|
|
585
|
+
if (!isHandleEvent(e)) return;
|
|
586
|
+
const root = roots.find();
|
|
587
|
+
if (root !== null) {
|
|
588
|
+
const px = Number.parseFloat(widthTarget(root).style.getPropertyValue("--dsh-chat-user-width"));
|
|
589
|
+
if (Number.isFinite(px) && px > 0) try {
|
|
590
|
+
window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
|
|
591
|
+
} catch {}
|
|
806
592
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
} catch (err) {
|
|
833
|
-
/* ignore */
|
|
834
|
-
}
|
|
835
|
-
syncButton();
|
|
836
|
-
syncMenu();
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
const onHandlePointerUp = (e) => {
|
|
840
|
-
if (!isHandleEvent(e)) return;
|
|
841
|
-
const root = findWidthRoot();
|
|
842
|
-
if (root !== null) {
|
|
843
|
-
const px = Number.parseFloat(
|
|
844
|
-
widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
|
|
845
|
-
);
|
|
846
|
-
if (Number.isFinite(px) && px > 0) {
|
|
847
|
-
try {
|
|
848
|
-
window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
|
|
849
|
-
} catch (err) {
|
|
850
|
-
/* ignore */
|
|
851
|
-
}
|
|
852
|
-
}
|
|
593
|
+
if (guideActive) finishGuide();
|
|
594
|
+
else emit();
|
|
595
|
+
};
|
|
596
|
+
const onHandlePointerCancel = () => {
|
|
597
|
+
if (!guideActive || !guideDragging) return;
|
|
598
|
+
endGuide(false);
|
|
599
|
+
};
|
|
600
|
+
document.addEventListener("pointerdown", onHandlePointerDown, true);
|
|
601
|
+
document.addEventListener("pointerup", onHandlePointerUp, true);
|
|
602
|
+
document.addEventListener("pointercancel", onHandlePointerCancel, true);
|
|
603
|
+
const actions = {
|
|
604
|
+
toggleMenu() {
|
|
605
|
+
menuOpen = !menuOpen;
|
|
606
|
+
emit();
|
|
607
|
+
},
|
|
608
|
+
closeMenu() {
|
|
609
|
+
if (!menuOpen) return;
|
|
610
|
+
menuOpen = false;
|
|
611
|
+
emit();
|
|
612
|
+
},
|
|
613
|
+
selectTier(tier) {
|
|
614
|
+
menuOpen = false;
|
|
615
|
+
if (tier === "custom" && readNumber("dsh.widthTierCustom") === null) {
|
|
616
|
+
beginCustomGuide();
|
|
617
|
+
return;
|
|
853
618
|
}
|
|
854
|
-
if (guideActive) finishGuide();
|
|
855
|
-
syncButton();
|
|
856
|
-
syncMenu();
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
/* A cancelled drag (touch gesture, window switch) ends without
|
|
860
|
-
* pointerup; without this handler the bubble would stay stuck
|
|
861
|
-
* in dragging mode and click-away would no longer dismiss it. */
|
|
862
|
-
const onHandlePointerCancel = () => {
|
|
863
|
-
/* The cancel target can be the capture element or the
|
|
864
|
-
* document; while a drag is active, end it regardless. */
|
|
865
|
-
if (!guideActive || !guideDragging) return;
|
|
866
619
|
endGuide(false);
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
window.clearTimeout(pulseTimer);
|
|
880
|
-
pulseTimer = window.setTimeout(() => {
|
|
881
|
-
btn.classList.remove("pulse");
|
|
882
|
-
pulseTimer = null;
|
|
883
|
-
}, 700);
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/* ---- re-render localized copy when the app language changes ---- */
|
|
887
|
-
const renderTexts = () => {
|
|
888
|
-
syncButton();
|
|
889
|
-
syncMenu();
|
|
890
|
-
if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
|
|
891
|
-
setText(guideTextEl, t("guide.hint"));
|
|
892
|
-
}
|
|
893
|
-
};
|
|
894
|
-
const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
|
|
895
|
-
|
|
896
|
-
/* ---- attach once the app shell has mounted ---- */
|
|
897
|
-
let attached = false;
|
|
898
|
-
let readyTimer = null;
|
|
899
|
-
let slowTimer = null;
|
|
900
|
-
let readyTimeout = null;
|
|
901
|
-
|
|
902
|
-
function stopWaiting() {
|
|
903
|
-
if (readyTimer !== null) {
|
|
904
|
-
clearInterval(readyTimer);
|
|
905
|
-
readyTimer = null;
|
|
906
|
-
}
|
|
907
|
-
if (slowTimer !== null) {
|
|
908
|
-
clearInterval(slowTimer);
|
|
909
|
-
slowTimer = null;
|
|
910
|
-
}
|
|
911
|
-
if (readyTimeout !== null) {
|
|
912
|
-
window.clearTimeout(readyTimeout);
|
|
913
|
-
readyTimeout = null;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/* The old gate matched [data-sidebar-collapsed] /
|
|
918
|
-
* [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
|
|
919
|
-
* markers conditionally (`sidebarCollapsed || void 0`) and
|
|
920
|
-
* removed data-details-collapsed, so with the sidebar expanded
|
|
921
|
-
* nothing ever matched and the button never mounted. Wait for
|
|
922
|
-
* the AppFrame overlay layer instead — `data-shell-overlay` is
|
|
923
|
-
* rendered unconditionally once the shell exists. */
|
|
924
|
-
function attach() {
|
|
925
|
-
if (attached || !body()) return;
|
|
926
|
-
if (document.querySelector("[data-shell-overlay]") === null) return;
|
|
927
|
-
attached = true;
|
|
928
|
-
stopWaiting();
|
|
929
|
-
try {
|
|
930
|
-
window.localStorage.removeItem(OLD_KEY);
|
|
931
|
-
} catch (e) {
|
|
932
|
-
/* ignore */
|
|
933
|
-
}
|
|
934
|
-
body().appendChild(btn);
|
|
935
|
-
body().appendChild(menu);
|
|
936
|
-
body().appendChild(guide);
|
|
937
|
-
syncButton();
|
|
938
|
-
const saved = getSavedTier();
|
|
939
|
-
if (saved !== "standard") applyTier(saved);
|
|
940
|
-
startHealing();
|
|
941
|
-
if (widthRoot !== null) observeWidthTarget(widthRoot);
|
|
620
|
+
applyTier(tier);
|
|
621
|
+
},
|
|
622
|
+
cancelGuide() {
|
|
623
|
+
endGuide(true);
|
|
624
|
+
},
|
|
625
|
+
measureGuide(width) {
|
|
626
|
+
const next = Math.round(width);
|
|
627
|
+
if (next <= 0 || next === guideWidth) return;
|
|
628
|
+
guideWidth = next;
|
|
629
|
+
if (!guideActive) return;
|
|
630
|
+
guideStyle = computeGuideStyle();
|
|
631
|
+
emit();
|
|
942
632
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
633
|
+
};
|
|
634
|
+
let attached = false;
|
|
635
|
+
let readyTimer = null;
|
|
636
|
+
let slowTimer = null;
|
|
637
|
+
let readyTimeout = null;
|
|
638
|
+
function stopWaiting() {
|
|
639
|
+
if (readyTimer !== null) {
|
|
947
640
|
clearInterval(readyTimer);
|
|
948
641
|
readyTimer = null;
|
|
949
|
-
/* A very slow boot should not leave the picker permanently
|
|
950
|
-
* unmounted: keep a low-frequency retry instead of giving up. */
|
|
951
|
-
slowTimer = setInterval(attach, 2000);
|
|
952
|
-
}, READY_TIMEOUT_MS);
|
|
953
|
-
window.addEventListener("load", attach);
|
|
954
|
-
|
|
955
|
-
/* Tier changes from another tab: adopt the stored tier without
|
|
956
|
-
* writing localStorage again (that would loop). */
|
|
957
|
-
const onStorage = (e) => {
|
|
958
|
-
if (!e || e.key !== KEY) return;
|
|
959
|
-
/* No storageArea identity check: spec does not require the
|
|
960
|
-
* event object to be the same Storage instance as
|
|
961
|
-
* window.localStorage, and dropping the event would break
|
|
962
|
-
* cross-tab sync. */
|
|
963
|
-
const next = e.newValue;
|
|
964
|
-
if (typeof next !== "string" || !TIERS.some((tier) => tier.id === next)) return;
|
|
965
|
-
if (currentTier() === next) return;
|
|
966
|
-
applyTier(next, false);
|
|
967
|
-
};
|
|
968
|
-
window.addEventListener("storage", onStorage);
|
|
969
|
-
|
|
970
|
-
/* ---- self-heal: re-apply the width when the chat tree
|
|
971
|
-
* remounts (conversation switch). Panels are never managed
|
|
972
|
-
* here — the user may expand/collapse either side any time;
|
|
973
|
-
* forcing the rightbar closed only retracts the grid track
|
|
974
|
-
* while the seat keeps rendering as an overlay that covers
|
|
975
|
-
* the conversation. Driven by DOM mutations, scheduled on the
|
|
976
|
-
* next animation frame (before the browser paints) instead of
|
|
977
|
-
* a fixed hot loop: the width must land on the fresh root
|
|
978
|
-
* before its first paint — otherwise the default width
|
|
979
|
-
* flashes for a frame. While the new root has not appeared
|
|
980
|
-
* yet (mid-remount), full scans are owned by a backoff retry;
|
|
981
|
-
* mutation-driven heals only try the cheap path walk so DOM
|
|
982
|
-
* churn cannot storm. ---- */
|
|
983
|
-
let rootMissing = false;
|
|
984
|
-
let rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
985
|
-
let rootRetryTimer = null;
|
|
986
|
-
let healFrame = null;
|
|
987
|
-
let resizeFrame = null;
|
|
988
|
-
let observer = null;
|
|
989
|
-
let fallbackTimer = null;
|
|
990
|
-
let widthObserver = null;
|
|
991
|
-
let observedTarget = null;
|
|
992
|
-
|
|
993
|
-
/* Return the frame id so heal and resize can be debounced and
|
|
994
|
-
* cancelled independently. The old helper always wrote the
|
|
995
|
-
* heal slot, so resizeFrame never became non-null and every
|
|
996
|
-
* resize event scheduled another frame. */
|
|
997
|
-
function scheduleFrame(cb) {
|
|
998
|
-
return typeof requestAnimationFrame === "function"
|
|
999
|
-
? requestAnimationFrame(cb)
|
|
1000
|
-
: window.setTimeout(cb, 0);
|
|
1001
642
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
643
|
+
if (slowTimer !== null) {
|
|
644
|
+
clearInterval(slowTimer);
|
|
645
|
+
slowTimer = null;
|
|
1005
646
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
* fixed/custom width) must follow without polling every
|
|
1010
|
-
* mutation. Guarded for browsers/tests without ResizeObserver. */
|
|
1011
|
-
function observeWidthTarget(root) {
|
|
1012
|
-
if (typeof ResizeObserver === "undefined") return;
|
|
1013
|
-
const target = widthTarget(root);
|
|
1014
|
-
if (target === observedTarget && widthObserver !== null) return;
|
|
1015
|
-
if (widthObserver !== null) {
|
|
1016
|
-
widthObserver.disconnect();
|
|
1017
|
-
widthObserver = null;
|
|
1018
|
-
}
|
|
1019
|
-
observedTarget = target;
|
|
1020
|
-
widthObserver = new ResizeObserver(() => {
|
|
1021
|
-
/* Never fight the app's native drag: it owns the width
|
|
1022
|
-
* while the user is moving a handle. */
|
|
1023
|
-
if (guideActive && guideDragging) return;
|
|
1024
|
-
const tier = currentTier();
|
|
1025
|
-
if (tier === "standard") return;
|
|
1026
|
-
applyContentWidth(tier);
|
|
1027
|
-
syncButton();
|
|
1028
|
-
syncMenu();
|
|
1029
|
-
});
|
|
1030
|
-
widthObserver.observe(target);
|
|
647
|
+
if (readyTimeout !== null) {
|
|
648
|
+
window.clearTimeout(readyTimeout);
|
|
649
|
+
readyTimeout = null;
|
|
1031
650
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}, rootRetryDelay);
|
|
651
|
+
}
|
|
652
|
+
function attach() {
|
|
653
|
+
if (attached || !body()) return;
|
|
654
|
+
if (document.querySelector("[data-shell-overlay]") === null) return;
|
|
655
|
+
attached = true;
|
|
656
|
+
stopWaiting();
|
|
657
|
+
try {
|
|
658
|
+
window.localStorage.removeItem(OLD_KEY);
|
|
659
|
+
} catch {}
|
|
660
|
+
const saved = getSavedTier$1();
|
|
661
|
+
if (saved !== "standard") applyTier(saved);
|
|
662
|
+
else emit();
|
|
663
|
+
startHealing();
|
|
664
|
+
const cached = roots.cached();
|
|
665
|
+
if (cached !== null) observeWidthTarget(cached);
|
|
666
|
+
}
|
|
667
|
+
readyTimer = setInterval(attach, 300);
|
|
668
|
+
readyTimeout = window.setTimeout(() => {
|
|
669
|
+
if (attached) return;
|
|
670
|
+
if (readyTimer !== null) {
|
|
671
|
+
clearInterval(readyTimer);
|
|
672
|
+
readyTimer = null;
|
|
1055
673
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
674
|
+
slowTimer = setInterval(attach, 2e3);
|
|
675
|
+
}, READY_TIMEOUT_MS);
|
|
676
|
+
window.addEventListener("load", attach);
|
|
677
|
+
const onStorage = (e) => {
|
|
678
|
+
if (!e || e.key !== "dsh.widthTier") return;
|
|
679
|
+
const next = e.newValue;
|
|
680
|
+
if (typeof next !== "string") return;
|
|
681
|
+
const found = TIERS.find((tier) => tier.id === next);
|
|
682
|
+
if (found === void 0) return;
|
|
683
|
+
if (currentTier() === found.id) return;
|
|
684
|
+
applyTier(found.id, false);
|
|
685
|
+
};
|
|
686
|
+
window.addEventListener("storage", onStorage);
|
|
687
|
+
let rootMissing = false;
|
|
688
|
+
let rootRetryDelay = 200;
|
|
689
|
+
let rootRetryTimer = null;
|
|
690
|
+
let healFrame = null;
|
|
691
|
+
let resizeFrame = null;
|
|
692
|
+
let observer = null;
|
|
693
|
+
let fallbackTimer = null;
|
|
694
|
+
let widthObserver = null;
|
|
695
|
+
let observedTarget = null;
|
|
696
|
+
function scheduleFrame(cb) {
|
|
697
|
+
return typeof requestAnimationFrame === "function" ? requestAnimationFrame(cb) : window.setTimeout(cb, 0);
|
|
698
|
+
}
|
|
699
|
+
function cancelFrame(id) {
|
|
700
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
|
|
701
|
+
else window.clearTimeout(id);
|
|
702
|
+
}
|
|
703
|
+
function observeWidthTarget(root) {
|
|
704
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
705
|
+
const target = widthTarget(root);
|
|
706
|
+
if (target === observedTarget && widthObserver !== null) return;
|
|
707
|
+
if (widthObserver !== null) {
|
|
708
|
+
widthObserver.disconnect();
|
|
709
|
+
widthObserver = null;
|
|
710
|
+
}
|
|
711
|
+
observedTarget = target;
|
|
712
|
+
widthObserver = new ResizeObserver(() => {
|
|
1063
713
|
if (guideActive && guideDragging) return;
|
|
1064
714
|
const tier = currentTier();
|
|
1065
715
|
if (tier === "standard") return;
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
716
|
+
applyContentWidth(tier);
|
|
717
|
+
emit();
|
|
718
|
+
});
|
|
719
|
+
widthObserver.observe(target);
|
|
720
|
+
}
|
|
721
|
+
function scheduleRootRetry() {
|
|
722
|
+
if (rootRetryTimer !== null) return;
|
|
723
|
+
rootRetryTimer = window.setTimeout(() => {
|
|
724
|
+
rootRetryTimer = null;
|
|
725
|
+
const tier = currentTier();
|
|
726
|
+
if (tier === "standard") return;
|
|
727
|
+
const prev = roots.cached();
|
|
728
|
+
const root = roots.find();
|
|
1076
729
|
if (root === null) {
|
|
1077
|
-
|
|
1078
|
-
rootMissing = true;
|
|
1079
|
-
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
1080
|
-
}
|
|
1081
|
-
/* Idempotent: keep a backoff retry alive even if the
|
|
1082
|
-
* previous one ended while this tier was standard. */
|
|
730
|
+
rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
|
|
1083
731
|
scheduleRootRetry();
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1086
|
-
if (rootMissing) {
|
|
732
|
+
} else {
|
|
1087
733
|
rootMissing = false;
|
|
1088
|
-
rootRetryDelay =
|
|
734
|
+
rootRetryDelay = 200;
|
|
735
|
+
if (root !== prev) {
|
|
736
|
+
applyContentWidth(tier);
|
|
737
|
+
emit();
|
|
738
|
+
}
|
|
739
|
+
observeWidthTarget(root);
|
|
1089
740
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
741
|
+
}, rootRetryDelay);
|
|
742
|
+
}
|
|
743
|
+
function heal(fromObserver) {
|
|
744
|
+
if (guideActive) updateGuideStyle();
|
|
745
|
+
if (guideActive && guideDragging) return;
|
|
746
|
+
const tier = currentTier();
|
|
747
|
+
if (tier === "standard") return;
|
|
748
|
+
const prev = roots.cached();
|
|
749
|
+
let root;
|
|
750
|
+
if (rootMissing || fromObserver) root = roots.find(false);
|
|
751
|
+
else root = roots.find();
|
|
752
|
+
if (root === null) {
|
|
753
|
+
if (!rootMissing) {
|
|
754
|
+
rootMissing = true;
|
|
755
|
+
rootRetryDelay = 200;
|
|
1099
756
|
}
|
|
757
|
+
scheduleRootRetry();
|
|
758
|
+
return;
|
|
1100
759
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
observer = new MutationObserver(() => {
|
|
1105
|
-
if (healFrame !== null) return;
|
|
1106
|
-
healFrame = scheduleFrame(() => {
|
|
1107
|
-
healFrame = null;
|
|
1108
|
-
heal(true);
|
|
1109
|
-
});
|
|
1110
|
-
});
|
|
1111
|
-
observer.observe(document.body, {
|
|
1112
|
-
subtree: true,
|
|
1113
|
-
childList: true,
|
|
1114
|
-
attributes: true,
|
|
1115
|
-
attributeFilter: ["data-sidebar-collapsed"]
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
/* Low-frequency fallback so self-heal still runs if the
|
|
1119
|
-
* observer misses something — cheap: no full scan while the
|
|
1120
|
-
* cached root is alive. */
|
|
1121
|
-
fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
|
|
760
|
+
if (rootMissing) {
|
|
761
|
+
rootMissing = false;
|
|
762
|
+
rootRetryDelay = 200;
|
|
1122
763
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
764
|
+
observeWidthTarget(root);
|
|
765
|
+
if (root !== prev) {
|
|
766
|
+
applyContentWidth(tier);
|
|
767
|
+
emit();
|
|
768
|
+
} else if (!fromObserver) {
|
|
769
|
+
applyContentWidth(tier);
|
|
770
|
+
emit();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function startHealing() {
|
|
774
|
+
if (typeof MutationObserver !== "undefined") {
|
|
775
|
+
observer = new MutationObserver(() => {
|
|
776
|
+
if (healFrame !== null) return;
|
|
777
|
+
healFrame = scheduleFrame(() => {
|
|
778
|
+
healFrame = null;
|
|
779
|
+
heal(true);
|
|
780
|
+
});
|
|
1138
781
|
});
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
782
|
+
observer.observe(document.body, {
|
|
783
|
+
subtree: true,
|
|
784
|
+
childList: true,
|
|
785
|
+
attributes: true,
|
|
786
|
+
attributeFilter: ["data-sidebar-collapsed"]
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
|
|
790
|
+
}
|
|
791
|
+
const onWindowResize = () => {
|
|
792
|
+
if (resizeFrame !== null) return;
|
|
793
|
+
resizeFrame = scheduleFrame(() => {
|
|
794
|
+
resizeFrame = null;
|
|
795
|
+
if (!(guideActive && guideDragging)) {
|
|
796
|
+
const tier = currentTier();
|
|
797
|
+
if (tier !== "standard") applyContentWidth(tier);
|
|
798
|
+
}
|
|
799
|
+
if (guideActive) guideStyle = computeGuideStyle();
|
|
800
|
+
emit();
|
|
801
|
+
});
|
|
802
|
+
};
|
|
803
|
+
window.addEventListener("resize", onWindowResize);
|
|
804
|
+
snapshot = buildSnapshot();
|
|
805
|
+
const unsubscribeLocale = options.locale.subscribe(() => {
|
|
806
|
+
if (guideActive && !guideDragging && guideMode === "hint") guideText = t("guide.hint");
|
|
807
|
+
emit();
|
|
808
|
+
});
|
|
809
|
+
attach();
|
|
810
|
+
return {
|
|
811
|
+
source: {
|
|
812
|
+
getSnapshot: () => snapshot,
|
|
813
|
+
subscribe(listener) {
|
|
814
|
+
listeners.add(listener);
|
|
815
|
+
return () => {
|
|
816
|
+
listeners.delete(listener);
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
actions,
|
|
821
|
+
dispose() {
|
|
1148
822
|
unsubscribeLocale();
|
|
1149
823
|
stopWaiting();
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
824
|
+
if (fallbackTimer !== null) {
|
|
825
|
+
clearInterval(fallbackTimer);
|
|
826
|
+
fallbackTimer = null;
|
|
827
|
+
}
|
|
828
|
+
if (rootRetryTimer !== null) {
|
|
829
|
+
window.clearTimeout(rootRetryTimer);
|
|
830
|
+
rootRetryTimer = null;
|
|
831
|
+
}
|
|
832
|
+
if (guideHideTimer !== null) {
|
|
833
|
+
window.clearTimeout(guideHideTimer);
|
|
834
|
+
guideHideTimer = null;
|
|
835
|
+
}
|
|
836
|
+
if (healFrame !== null) {
|
|
837
|
+
cancelFrame(healFrame);
|
|
838
|
+
healFrame = null;
|
|
839
|
+
}
|
|
840
|
+
if (resizeFrame !== null) {
|
|
841
|
+
cancelFrame(resizeFrame);
|
|
842
|
+
resizeFrame = null;
|
|
843
|
+
}
|
|
1156
844
|
if (widthObserver !== null) {
|
|
1157
845
|
widthObserver.disconnect();
|
|
1158
846
|
widthObserver = null;
|
|
@@ -1162,54 +850,377 @@ window.__ModuleLoader__.load({
|
|
|
1162
850
|
window.removeEventListener("load", attach);
|
|
1163
851
|
window.removeEventListener("storage", onStorage);
|
|
1164
852
|
if (observer !== null) observer.disconnect();
|
|
1165
|
-
document.removeEventListener("click", onDocClick);
|
|
1166
|
-
document.removeEventListener("keydown", onDocKey);
|
|
1167
853
|
document.removeEventListener("pointerdown", onHandlePointerDown, true);
|
|
1168
854
|
document.removeEventListener("pointerup", onHandlePointerUp, true);
|
|
1169
855
|
document.removeEventListener("pointercancel", onHandlePointerCancel, true);
|
|
1170
856
|
document.removeEventListener("pointermove", onGuidePointerMove, true);
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (
|
|
1174
|
-
if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
|
|
1175
|
-
/* Hand the width back to the app default. Only a value this
|
|
1176
|
-
* plugin wrote is removed, so a native drag preference that
|
|
1177
|
-
* predates the plugin survives unload. */
|
|
1178
|
-
if (widthRoot !== null) {
|
|
1179
|
-
widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
|
|
1180
|
-
}
|
|
857
|
+
removeStyles();
|
|
858
|
+
const root = roots.cached();
|
|
859
|
+
if (root !== null) widthTarget(root).style.removeProperty("--dsh-chat-user-width");
|
|
1181
860
|
if (preferenceOwned) {
|
|
1182
861
|
try {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (preferenceWritten !== null
|
|
1186
|
-
&& window.localStorage.getItem(APP_PREF_KEY) === preferenceWritten) {
|
|
1187
|
-
window.localStorage.removeItem(APP_PREF_KEY);
|
|
1188
|
-
}
|
|
1189
|
-
} catch (e) {
|
|
1190
|
-
/* ignore */
|
|
1191
|
-
}
|
|
862
|
+
if (preferenceWritten !== null && window.localStorage.getItem("dsh.conversation.contentWidth") === preferenceWritten) window.localStorage.removeItem(APP_PREF_KEY);
|
|
863
|
+
} catch {}
|
|
1192
864
|
preferenceOwned = false;
|
|
1193
865
|
preferenceWritten = null;
|
|
1194
866
|
}
|
|
1195
|
-
/* Undo a collapse this plugin owned; a manual collapse is
|
|
1196
|
-
* left alone. */
|
|
1197
867
|
if (collapsedByTier) {
|
|
1198
868
|
try {
|
|
1199
869
|
if (isSidebarCollapsed()) layout.toggleSidebar();
|
|
1200
|
-
} catch
|
|
1201
|
-
/* ignore */
|
|
1202
|
-
}
|
|
870
|
+
} catch {}
|
|
1203
871
|
collapsedByTier = false;
|
|
1204
872
|
}
|
|
1205
873
|
guideHandle = null;
|
|
1206
|
-
body().removeAttribute(
|
|
1207
|
-
body().removeAttribute(
|
|
874
|
+
body().removeAttribute(TIER_ATTR);
|
|
875
|
+
body().removeAttribute(GUIDE_ATTR);
|
|
876
|
+
listeners.clear();
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
// src/client/locales.ts
|
|
881
|
+
/** Dictionary namespace owned by this plugin. */
|
|
882
|
+
const NS = "width-tiers";
|
|
883
|
+
/**
|
|
884
|
+
* Every shipped dictionary, keyed by the locale id the DSH locale service
|
|
885
|
+
* accepts. The set matches the languages DSH 0.2.2 ships (zh, en, ja, es,
|
|
886
|
+
* fr, de, ru, pt); other languages fall back through English.
|
|
887
|
+
*/
|
|
888
|
+
const dictionaries = {
|
|
889
|
+
zh: {
|
|
890
|
+
"tier.standard": "标准",
|
|
891
|
+
"tier.medium": "中等",
|
|
892
|
+
"tier.wide": "宽",
|
|
893
|
+
"tier.ultra": "超宽",
|
|
894
|
+
"tier.full": "全宽",
|
|
895
|
+
"tier.custom": "自定义",
|
|
896
|
+
"value.custom.unset": "未设置",
|
|
897
|
+
"guide.hint": "拖拽右侧边缘调整对话宽度",
|
|
898
|
+
"button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
|
|
899
|
+
"button.title.summary": "对话区宽度档位:{label}({value})",
|
|
900
|
+
"button.title.hint": "点击选择:{list}"
|
|
901
|
+
},
|
|
902
|
+
en: {
|
|
903
|
+
"tier.standard": "Standard",
|
|
904
|
+
"tier.medium": "Medium",
|
|
905
|
+
"tier.wide": "Wide",
|
|
906
|
+
"tier.ultra": "Ultra",
|
|
907
|
+
"tier.full": "Full",
|
|
908
|
+
"tier.custom": "Custom",
|
|
909
|
+
"value.custom.unset": "Not set",
|
|
910
|
+
"guide.hint": "Drag the right edge to resize",
|
|
911
|
+
"button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
|
|
912
|
+
"button.title.summary": "Chat width tier: {label} ({value})",
|
|
913
|
+
"button.title.hint": "Click to choose: {list}"
|
|
914
|
+
},
|
|
915
|
+
ja: {
|
|
916
|
+
"tier.standard": "標準",
|
|
917
|
+
"tier.medium": "中",
|
|
918
|
+
"tier.wide": "ワイド",
|
|
919
|
+
"tier.ultra": "ウルトラワイド",
|
|
920
|
+
"tier.full": "全幅",
|
|
921
|
+
"tier.custom": "カスタム",
|
|
922
|
+
"value.custom.unset": "未設定",
|
|
923
|
+
"guide.hint": "右端をドラッグして会話幅を調整",
|
|
924
|
+
"button.aria": "会話エリアの幅設定:{label}({value})。クリックして選択",
|
|
925
|
+
"button.title.summary": "会話エリアの幅設定:{label}({value})",
|
|
926
|
+
"button.title.hint": "クリックして選択:{list}"
|
|
927
|
+
},
|
|
928
|
+
es: {
|
|
929
|
+
"tier.standard": "Estándar",
|
|
930
|
+
"tier.medium": "Medio",
|
|
931
|
+
"tier.wide": "Ancho",
|
|
932
|
+
"tier.ultra": "Muy ancho",
|
|
933
|
+
"tier.full": "Completo",
|
|
934
|
+
"tier.custom": "Personalizado",
|
|
935
|
+
"value.custom.unset": "Sin definir",
|
|
936
|
+
"guide.hint": "Arrastra el borde derecho para ajustar el ancho",
|
|
937
|
+
"button.aria": "Ancho del área de chat: {label} ({value}). Haz clic para elegir un nivel.",
|
|
938
|
+
"button.title.summary": "Ancho del área de chat: {label} ({value})",
|
|
939
|
+
"button.title.hint": "Haz clic para elegir: {list}"
|
|
940
|
+
},
|
|
941
|
+
fr: {
|
|
942
|
+
"tier.standard": "Standard",
|
|
943
|
+
"tier.medium": "Moyen",
|
|
944
|
+
"tier.wide": "Large",
|
|
945
|
+
"tier.ultra": "Très large",
|
|
946
|
+
"tier.full": "Pleine largeur",
|
|
947
|
+
"tier.custom": "Personnalisé",
|
|
948
|
+
"value.custom.unset": "Non défini",
|
|
949
|
+
"guide.hint": "Faites glisser le bord droit pour ajuster la largeur",
|
|
950
|
+
"button.aria": "Largeur de la zone de chat : {label} ({value}). Cliquez pour choisir un palier.",
|
|
951
|
+
"button.title.summary": "Largeur de la zone de chat : {label} ({value})",
|
|
952
|
+
"button.title.hint": "Cliquez pour choisir : {list}"
|
|
953
|
+
},
|
|
954
|
+
de: {
|
|
955
|
+
"tier.standard": "Standard",
|
|
956
|
+
"tier.medium": "Mittel",
|
|
957
|
+
"tier.wide": "Breit",
|
|
958
|
+
"tier.ultra": "Sehr breit",
|
|
959
|
+
"tier.full": "Volle Breite",
|
|
960
|
+
"tier.custom": "Benutzerdefiniert",
|
|
961
|
+
"value.custom.unset": "Nicht festgelegt",
|
|
962
|
+
"guide.hint": "Rechten Rand ziehen, um die Breite anzupassen",
|
|
963
|
+
"button.aria": "Chatbreite: {label} ({value}). Klicken, um eine Stufe zu wählen.",
|
|
964
|
+
"button.title.summary": "Chatbreite: {label} ({value})",
|
|
965
|
+
"button.title.hint": "Klicken zum Auswählen: {list}"
|
|
966
|
+
},
|
|
967
|
+
ru: {
|
|
968
|
+
"tier.standard": "Стандартная",
|
|
969
|
+
"tier.medium": "Средняя",
|
|
970
|
+
"tier.wide": "Широкая",
|
|
971
|
+
"tier.ultra": "Очень широкая",
|
|
972
|
+
"tier.full": "На всю ширину",
|
|
973
|
+
"tier.custom": "Своя",
|
|
974
|
+
"value.custom.unset": "Не задано",
|
|
975
|
+
"guide.hint": "Перетащите правый край, чтобы изменить ширину",
|
|
976
|
+
"button.aria": "Ширина области чата: {label} ({value}). Нажмите, чтобы выбрать уровень.",
|
|
977
|
+
"button.title.summary": "Ширина области чата: {label} ({value})",
|
|
978
|
+
"button.title.hint": "Нажмите, чтобы выбрать: {list}"
|
|
979
|
+
},
|
|
980
|
+
pt: {
|
|
981
|
+
"tier.standard": "Padrão",
|
|
982
|
+
"tier.medium": "Médio",
|
|
983
|
+
"tier.wide": "Largo",
|
|
984
|
+
"tier.ultra": "Muito largo",
|
|
985
|
+
"tier.full": "Largura total",
|
|
986
|
+
"tier.custom": "Personalizado",
|
|
987
|
+
"value.custom.unset": "Não definido",
|
|
988
|
+
"guide.hint": "Arraste a borda direita para ajustar a largura",
|
|
989
|
+
"button.aria": "Largura da área de chat: {label} ({value}). Clique para escolher um nível.",
|
|
990
|
+
"button.title.summary": "Largura da área de chat: {label} ({value})",
|
|
991
|
+
"button.title.hint": "Clique para escolher: {list}"
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
// src/client/WidthTierPicker.tsx
|
|
995
|
+
/** How long the selection ring stays on the button. */
|
|
996
|
+
const PULSE_MS = 700;
|
|
997
|
+
/** Six horizontal bars; one per tier, lit up to the selected index. */
|
|
998
|
+
function LevelIcon({ tier }) {
|
|
999
|
+
const index = TIERS.findIndex((candidate) => candidate.id === tier);
|
|
1000
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1001
|
+
viewBox: "0 0 16 16",
|
|
1002
|
+
width: "16",
|
|
1003
|
+
height: "16",
|
|
1004
|
+
"aria-hidden": "true",
|
|
1005
|
+
focusable: "false",
|
|
1006
|
+
children: [
|
|
1007
|
+
3.5,
|
|
1008
|
+
5.3,
|
|
1009
|
+
7.1,
|
|
1010
|
+
8.9,
|
|
1011
|
+
10.7,
|
|
1012
|
+
12.5
|
|
1013
|
+
].map((width, bar) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
|
|
1014
|
+
x: 1.5,
|
|
1015
|
+
y: .75 + bar * 2.55,
|
|
1016
|
+
width,
|
|
1017
|
+
height: 1.6,
|
|
1018
|
+
rx: .8,
|
|
1019
|
+
fill: "currentColor",
|
|
1020
|
+
opacity: bar <= index ? 1 : .3
|
|
1021
|
+
}, bar))
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Floating tier picker and drag guide, mounted in `shell.overlay`.
|
|
1026
|
+
*
|
|
1027
|
+
* All width state, persistence and native-handle interception live in the
|
|
1028
|
+
* controller; this component only renders the snapshot and dispatches
|
|
1029
|
+
* actions. Menu focus, Escape/Tab handling, click-away dismissal and the
|
|
1030
|
+
* selection ring are view concerns and stay local here.
|
|
1031
|
+
* @param props - snapshot hook and controller actions.
|
|
1032
|
+
* @returns the button, menu and guide inside one overlay host element.
|
|
1033
|
+
*/
|
|
1034
|
+
function WidthTierPicker({ usePicker, actions }) {
|
|
1035
|
+
const state = usePicker((snapshot) => snapshot);
|
|
1036
|
+
const buttonRef = (0, react.useRef)(null);
|
|
1037
|
+
const menuRef = (0, react.useRef)(null);
|
|
1038
|
+
const wrapperRef = (0, react.useRef)(null);
|
|
1039
|
+
const guideRef = (0, react.useRef)(null);
|
|
1040
|
+
const stateRef = (0, react.useRef)(state);
|
|
1041
|
+
const [pulsing, setPulsing] = (0, react.useState)(false);
|
|
1042
|
+
(0, react.useLayoutEffect)(() => {
|
|
1043
|
+
stateRef.current = state;
|
|
1044
|
+
}, [state]);
|
|
1045
|
+
(0, react.useEffect)(() => {
|
|
1046
|
+
if (state.pulse === 0) return;
|
|
1047
|
+
setPulsing(false);
|
|
1048
|
+
const frame = requestAnimationFrame(() => setPulsing(true));
|
|
1049
|
+
const timer = window.setTimeout(() => setPulsing(false), PULSE_MS);
|
|
1050
|
+
return () => {
|
|
1051
|
+
cancelAnimationFrame(frame);
|
|
1052
|
+
window.clearTimeout(timer);
|
|
1053
|
+
};
|
|
1054
|
+
}, [state.pulse]);
|
|
1055
|
+
(0, react.useEffect)(() => {
|
|
1056
|
+
if (!state.menuOpen || menuRef.current === null) return;
|
|
1057
|
+
const rows = Array.from(menuRef.current.querySelectorAll("button[data-tier]"));
|
|
1058
|
+
(rows.find((row) => row.dataset.tier === state.tier) ?? rows[0])?.focus();
|
|
1059
|
+
}, [state.menuOpen, state.tier]);
|
|
1060
|
+
(0, react.useLayoutEffect)(() => {
|
|
1061
|
+
if (!state.guide.active) return;
|
|
1062
|
+
const width = guideRef.current?.offsetWidth ?? 0;
|
|
1063
|
+
if (width > 0) actions.measureGuide(width);
|
|
1064
|
+
}, [
|
|
1065
|
+
state.guide.active,
|
|
1066
|
+
state.guide.text,
|
|
1067
|
+
state.guide.mode,
|
|
1068
|
+
actions
|
|
1069
|
+
]);
|
|
1070
|
+
(0, react.useEffect)(() => {
|
|
1071
|
+
const onDocClick = (event) => {
|
|
1072
|
+
const target = event.target;
|
|
1073
|
+
if (!(target instanceof Node)) return;
|
|
1074
|
+
const current = stateRef.current;
|
|
1075
|
+
const inside = wrapperRef.current?.contains(target) ?? false;
|
|
1076
|
+
if (current.menuOpen && !inside) actions.closeMenu();
|
|
1077
|
+
if (current.guide.active && !current.guide.dragging && !inside && !(target instanceof Element && target.closest("[data-width-handle]") !== null)) actions.cancelGuide();
|
|
1078
|
+
};
|
|
1079
|
+
const onDocKey = (event) => {
|
|
1080
|
+
const current = stateRef.current;
|
|
1081
|
+
if (event.key === "Escape") {
|
|
1082
|
+
if (current.menuOpen) {
|
|
1083
|
+
actions.closeMenu();
|
|
1084
|
+
buttonRef.current?.focus();
|
|
1085
|
+
}
|
|
1086
|
+
if (current.guide.active) actions.cancelGuide();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (!current.menuOpen) return;
|
|
1090
|
+
if (event.key === "Tab") {
|
|
1091
|
+
actions.closeMenu();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const rows = Array.from(menuRef.current?.querySelectorAll("button[data-tier]") ?? []);
|
|
1095
|
+
if (rows.length === 0) return;
|
|
1096
|
+
const active = document.activeElement;
|
|
1097
|
+
const index = active === null ? -1 : rows.indexOf(active);
|
|
1098
|
+
let next = -1;
|
|
1099
|
+
if (event.key === "ArrowDown") next = index < 0 ? 0 : (index + 1) % rows.length;
|
|
1100
|
+
else if (event.key === "ArrowUp") next = index < 0 ? rows.length - 1 : (index - 1 + rows.length) % rows.length;
|
|
1101
|
+
else if (event.key === "Home") next = 0;
|
|
1102
|
+
else if (event.key === "End") next = rows.length - 1;
|
|
1103
|
+
if (next < 0) return;
|
|
1104
|
+
event.preventDefault();
|
|
1105
|
+
rows[next]?.focus();
|
|
1106
|
+
};
|
|
1107
|
+
document.addEventListener("click", onDocClick);
|
|
1108
|
+
document.addEventListener("keydown", onDocKey);
|
|
1109
|
+
return () => {
|
|
1110
|
+
document.removeEventListener("click", onDocClick);
|
|
1111
|
+
document.removeEventListener("keydown", onDocKey);
|
|
1112
|
+
};
|
|
1113
|
+
}, [actions]);
|
|
1114
|
+
const className = [state.button.active ? "active" : "", pulsing ? "pulse" : ""].filter(Boolean).join(" ");
|
|
1115
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1116
|
+
ref: wrapperRef,
|
|
1117
|
+
"data-dsh-width-tiers-host": "",
|
|
1118
|
+
children: [
|
|
1119
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1120
|
+
ref: buttonRef,
|
|
1121
|
+
type: "button",
|
|
1122
|
+
id: "dsh-wide-toggle",
|
|
1123
|
+
"aria-haspopup": "menu",
|
|
1124
|
+
"aria-expanded": state.menuOpen,
|
|
1125
|
+
"aria-label": state.button.ariaLabel,
|
|
1126
|
+
title: state.button.title,
|
|
1127
|
+
className: className || void 0,
|
|
1128
|
+
onClick: () => actions.toggleMenu(),
|
|
1129
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LevelIcon, { tier: state.tier })
|
|
1130
|
+
}),
|
|
1131
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1132
|
+
ref: menuRef,
|
|
1133
|
+
id: "dsh-width-menu",
|
|
1134
|
+
role: state.menuOpen ? "menu" : void 0,
|
|
1135
|
+
hidden: !state.menuOpen,
|
|
1136
|
+
children: state.items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1137
|
+
type: "button",
|
|
1138
|
+
role: "menuitemradio",
|
|
1139
|
+
"data-tier": item.id,
|
|
1140
|
+
tabIndex: -1,
|
|
1141
|
+
"aria-checked": item.selected,
|
|
1142
|
+
className: item.selected ? "selected" : void 0,
|
|
1143
|
+
onClick: () => {
|
|
1144
|
+
actions.selectTier(item.id);
|
|
1145
|
+
buttonRef.current?.focus();
|
|
1146
|
+
},
|
|
1147
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1148
|
+
className: "dshwm-label",
|
|
1149
|
+
children: item.label
|
|
1150
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1151
|
+
className: "dshwm-value",
|
|
1152
|
+
children: item.value
|
|
1153
|
+
})]
|
|
1154
|
+
}, item.id))
|
|
1155
|
+
}),
|
|
1156
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1157
|
+
ref: guideRef,
|
|
1158
|
+
id: "dsh-width-guide",
|
|
1159
|
+
"aria-hidden": "true",
|
|
1160
|
+
hidden: !state.guide.active,
|
|
1161
|
+
"data-mode": state.guide.mode,
|
|
1162
|
+
style: state.guide.style,
|
|
1163
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1164
|
+
viewBox: "0 0 16 16",
|
|
1165
|
+
width: "14",
|
|
1166
|
+
height: "14",
|
|
1167
|
+
"aria-hidden": "true",
|
|
1168
|
+
focusable: "false",
|
|
1169
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1170
|
+
d: "M6 3 2 8l4 5M10 3l4 5-4 5",
|
|
1171
|
+
fill: "none",
|
|
1172
|
+
stroke: "currentColor",
|
|
1173
|
+
strokeWidth: 1.5,
|
|
1174
|
+
strokeLinecap: "round",
|
|
1175
|
+
strokeLinejoin: "round"
|
|
1176
|
+
})
|
|
1177
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1178
|
+
className: "dshwg-text",
|
|
1179
|
+
children: state.guide.text
|
|
1180
|
+
})]
|
|
1181
|
+
})
|
|
1182
|
+
]
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
// src/client/index.ts
|
|
1186
|
+
/** Services required by the browser bundle. */
|
|
1187
|
+
const inject = [
|
|
1188
|
+
"slots",
|
|
1189
|
+
"layout",
|
|
1190
|
+
"locale"
|
|
1191
|
+
];
|
|
1192
|
+
/**
|
|
1193
|
+
* Client plugin body: register the dictionaries, start the width-tier
|
|
1194
|
+
* controller, then contribute the React picker to the frame-wide overlay
|
|
1195
|
+
* layer. The controller owns the width axis and persistence; the slot entry
|
|
1196
|
+
* owns only its own render lifetime.
|
|
1197
|
+
* @param ctx - client root context (slots + layout + locale injected).
|
|
1198
|
+
*/
|
|
1199
|
+
function apply(ctx) {
|
|
1200
|
+
ctx.effect(() => {
|
|
1201
|
+
const disposers = Object.entries(dictionaries).map(([locale, dictionary]) => ctx.locale.register(NS, locale, dictionary));
|
|
1202
|
+
return () => {
|
|
1203
|
+
for (const dispose of disposers) dispose();
|
|
1208
1204
|
};
|
|
1209
|
-
}, "dsh-width-tiers:
|
|
1205
|
+
}, "dsh-width-tiers: dictionaries");
|
|
1206
|
+
const t = ctx.locale.bind(NS);
|
|
1207
|
+
const controller = createWidthTiers({
|
|
1208
|
+
layout: ctx.layout,
|
|
1209
|
+
locale: ctx.locale,
|
|
1210
|
+
t
|
|
1211
|
+
});
|
|
1212
|
+
ctx.effect(() => () => controller.dispose(), "dsh-width-tiers: controller");
|
|
1213
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
1214
|
+
name: "shell.overlay",
|
|
1215
|
+
id: "width-tiers",
|
|
1216
|
+
order: 100,
|
|
1217
|
+
locale: NS,
|
|
1218
|
+
inject: () => ({
|
|
1219
|
+
hooks: { picker: controller.source },
|
|
1220
|
+
actions: controller.actions
|
|
1221
|
+
})
|
|
1222
|
+
}, WidthTierPicker));
|
|
1210
1223
|
}
|
|
1211
|
-
//#endregion
|
|
1212
|
-
|
|
1213
1224
|
exports.apply = apply;
|
|
1214
1225
|
exports.inject = inject;
|
|
1215
1226
|
return module.exports;
|