dsh-cool-theme 0.5.0 → 0.6.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 +3 -2
- package/README.zh.md +3 -2
- package/lib/client.js +476 -32
- package/lib/index.js +532 -6
- package/lib/types/client/components.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +22 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/presets.d.ts +8 -836
- package/lib/types/client/presets.d.ts.map +1 -1
- package/lib/types/client/theme-files.d.ts +14 -0
- package/lib/types/client/theme-files.d.ts.map +1 -1
- package/lib/types/client/theme.d.ts.map +1 -1
- package/lib/types/contract.d.ts +35 -0
- package/lib/types/contract.d.ts.map +1 -1
- package/lib/types/host/index.d.ts.map +1 -1
- package/lib/types/host/theme-files.d.ts +6 -0
- package/lib/types/host/theme-files.d.ts.map +1 -1
- package/lib/types/host/theme-share.d.ts +47 -0
- package/lib/types/host/theme-share.d.ts.map +1 -0
- package/lib/types/host/zip.d.ts +39 -0
- package/lib/types/host/zip.d.ts.map +1 -0
- package/lib/types/presets-ids.d.ts +17 -0
- package/lib/types/presets-ids.d.ts.map +1 -0
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -40,6 +40,61 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
const CUSTOM_ACTIVE_STORAGE_KEY = "cool-theme-custom-active";
|
|
41
41
|
const CUSTOM_MIGRATED_STORAGE_KEY = "cool-theme-custom-migrated";
|
|
42
42
|
const THEME_API_PATH_THEMES = `/cool-theme/api/themes`;
|
|
43
|
+
const THEME_ARCHIVE_EXTENSION = ".zip";
|
|
44
|
+
/**
|
|
45
|
+
* Import bounds. A theme is a small JSON document, so anything past these caps
|
|
46
|
+
* is refused before it is parsed rather than after it has been held in memory.
|
|
47
|
+
*/
|
|
48
|
+
const THEME_ARCHIVE_MAX_BYTES = 8388608;
|
|
49
|
+
/** Route segments below {@link THEME_API_PATH_THEMES}: `/<id>/export`, `/import`. */
|
|
50
|
+
const THEME_API_SEGMENT_EXPORT = "export";
|
|
51
|
+
const THEME_API_PATH_IMPORT = `${THEME_API_PATH_THEMES}/import`;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/presets-ids.ts
|
|
54
|
+
/**
|
|
55
|
+
* The preset ids, in one place both halves can read.
|
|
56
|
+
*
|
|
57
|
+
* `presets.ts` carries the colour maps and is client-only; the Host needs the
|
|
58
|
+
* same id vocabulary to tell an archive that derives from a preset we ship from
|
|
59
|
+
* one that does not, so the names live here and that file checks itself against
|
|
60
|
+
* them.
|
|
61
|
+
*/
|
|
62
|
+
const PRESET_IDS = [
|
|
63
|
+
"aura",
|
|
64
|
+
"ayu",
|
|
65
|
+
"catppuccin",
|
|
66
|
+
"catppuccin-frappe",
|
|
67
|
+
"catppuccin-macchiato",
|
|
68
|
+
"cobalt2",
|
|
69
|
+
"cursor",
|
|
70
|
+
"dracula",
|
|
71
|
+
"dsh",
|
|
72
|
+
"everforest",
|
|
73
|
+
"flexoki",
|
|
74
|
+
"github",
|
|
75
|
+
"gruvbox",
|
|
76
|
+
"kanagawa",
|
|
77
|
+
"lucent-orng",
|
|
78
|
+
"material",
|
|
79
|
+
"matrix",
|
|
80
|
+
"mercury",
|
|
81
|
+
"monokai",
|
|
82
|
+
"nightowl",
|
|
83
|
+
"nord",
|
|
84
|
+
"onedark",
|
|
85
|
+
"opencode",
|
|
86
|
+
"orng",
|
|
87
|
+
"osaka-jade",
|
|
88
|
+
"palenight",
|
|
89
|
+
"rosepine",
|
|
90
|
+
"solarized",
|
|
91
|
+
"synthwave84",
|
|
92
|
+
"system",
|
|
93
|
+
"tokyonight",
|
|
94
|
+
"vercel",
|
|
95
|
+
"vesper",
|
|
96
|
+
"zenburn"
|
|
97
|
+
];
|
|
43
98
|
//#endregion
|
|
44
99
|
//#region src/client/presets/_helpers.ts
|
|
45
100
|
/**
|
|
@@ -1919,6 +1974,12 @@ window.__ModuleLoader__.load({
|
|
|
1919
1974
|
});
|
|
1920
1975
|
//#endregion
|
|
1921
1976
|
//#region src/client/presets.ts
|
|
1977
|
+
/**
|
|
1978
|
+
* Every shipped preset, one entry per {@link PRESET_IDS} name. The mapped type
|
|
1979
|
+
* makes the two lists agree at compile time: a preset without a colour map, or a
|
|
1980
|
+
* name with no entry here, is a type error rather than a theme that goes missing
|
|
1981
|
+
* from the picker (or from an import's validation).
|
|
1982
|
+
*/
|
|
1922
1983
|
const PRESETS = {
|
|
1923
1984
|
aura,
|
|
1924
1985
|
ayu,
|
|
@@ -1993,7 +2054,7 @@ window.__ModuleLoader__.load({
|
|
|
1993
2054
|
const presetOptions = [{
|
|
1994
2055
|
value: "dsh",
|
|
1995
2056
|
label: PRESETS.dsh.label
|
|
1996
|
-
}, ...
|
|
2057
|
+
}, ...PRESET_IDS.filter((id) => id !== "dsh").map((id) => ({
|
|
1997
2058
|
value: id,
|
|
1998
2059
|
label: PRESETS[id].label
|
|
1999
2060
|
}))];
|
|
@@ -2665,6 +2726,69 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2665
2726
|
async function removeTheme(id) {
|
|
2666
2727
|
await callApi(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
2667
2728
|
}
|
|
2729
|
+
/** One theme the Host stored on the client's behalf, as a stored document. */
|
|
2730
|
+
function storedTheme(payload) {
|
|
2731
|
+
const doc = isRecord(payload.theme) ? payload.theme : {};
|
|
2732
|
+
if (typeof doc.id !== "string" || typeof doc.name !== "string") throw new Error("the Host did not report a stored theme");
|
|
2733
|
+
return {
|
|
2734
|
+
id: doc.id,
|
|
2735
|
+
name: doc.name
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Download one theme as an archive. The response is the archive itself rather
|
|
2740
|
+
* than JSON, so this is the one call that does not go through {@link callApi}.
|
|
2741
|
+
*/
|
|
2742
|
+
async function exportTheme(id, name) {
|
|
2743
|
+
const response = await fetch(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}/${THEME_API_SEGMENT_EXPORT}`);
|
|
2744
|
+
if (response.status === 404 || response.status === 405) throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
|
|
2745
|
+
if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`);
|
|
2746
|
+
saveBlob(await response.blob(), suggestedName(response.headers.get("content-disposition"), name));
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* The archive's own filename when the Host sent one, else the theme's name. The
|
|
2750
|
+
* Host is the authority because only it knows the theme id behind a bad name.
|
|
2751
|
+
*/
|
|
2752
|
+
function suggestedName(disposition, fallback) {
|
|
2753
|
+
const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
2754
|
+
if (encoded !== void 0) try {
|
|
2755
|
+
return decodeURIComponent(encoded);
|
|
2756
|
+
} catch {}
|
|
2757
|
+
const plain = disposition?.match(/filename="?([^";]+)"?/i)?.[1];
|
|
2758
|
+
if (plain !== void 0 && plain !== "") return plain;
|
|
2759
|
+
const name = fallback.trim() === "" ? "theme" : fallback.trim();
|
|
2760
|
+
return name.endsWith(".zip") ? name : `${name}${THEME_ARCHIVE_EXTENSION}`;
|
|
2761
|
+
}
|
|
2762
|
+
/** Hand one blob to the browser's own download machinery. */
|
|
2763
|
+
function saveBlob(blob, fileName) {
|
|
2764
|
+
const url = URL.createObjectURL(blob);
|
|
2765
|
+
try {
|
|
2766
|
+
const anchor = document.createElement("a");
|
|
2767
|
+
anchor.href = url;
|
|
2768
|
+
anchor.download = fileName;
|
|
2769
|
+
anchor.rel = "noopener";
|
|
2770
|
+
document.body.append(anchor);
|
|
2771
|
+
anchor.click();
|
|
2772
|
+
anchor.remove();
|
|
2773
|
+
} finally {
|
|
2774
|
+
setTimeout(() => {
|
|
2775
|
+
URL.revokeObjectURL(url);
|
|
2776
|
+
}, 0);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Upload one archive. The Host validates it, names the theme and stores it, so
|
|
2781
|
+
* a success means the roster already has the theme — the answer carries what to
|
|
2782
|
+
* report, not what the client still has to write.
|
|
2783
|
+
*/
|
|
2784
|
+
async function importTheme(file) {
|
|
2785
|
+
if (file.size > 8388608) throw new Error(`archive is larger than ${String(Math.round(THEME_ARCHIVE_MAX_BYTES / 1048576))} MB`);
|
|
2786
|
+
return storedTheme(await callApi(THEME_API_PATH_IMPORT, {
|
|
2787
|
+
method: "POST",
|
|
2788
|
+
headers: { "content-type": "application/octet-stream" },
|
|
2789
|
+
body: file
|
|
2790
|
+
}));
|
|
2791
|
+
}
|
|
2668
2792
|
/**
|
|
2669
2793
|
* The themes a browser saved before files became the store, minus those the
|
|
2670
2794
|
* Host already has.
|
|
@@ -2684,6 +2808,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2684
2808
|
*/
|
|
2685
2809
|
/** Reserved menu value that switches to the user-defined theme. */
|
|
2686
2810
|
const CUSTOM_SELECTION = CUSTOM_PRESET_ID;
|
|
2811
|
+
/**
|
|
2812
|
+
* Which message an import failure deserves. The Host answers with its own
|
|
2813
|
+
* reason, so the shape of that reason is what selects the localized text; an
|
|
2814
|
+
* unrecognised failure keeps the Host's own words rather than hiding them.
|
|
2815
|
+
*/
|
|
2816
|
+
function importFailureMessage(t, error) {
|
|
2817
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2818
|
+
if (error instanceof ThemeApiUnavailableError) return t("custom.toast.unavailable");
|
|
2819
|
+
return t(/not a zip archive|zip central directory|zip local header|zip64|unsupported zip compression/i.test(message) ? "custom.importFailed.notArchive" : /missing theme\.json/i.test(message) ? "custom.importFailed.noDocument" : /newer than this plugin/i.test(message) ? "custom.importFailed.tooNew" : /carries media|unsupported media type|unusable media path/i.test(message) ? "custom.importFailed.media" : /larger than|too large|too many files/i.test(message) ? "custom.importFailed.tooLarge" : /not valid JSON|must be|unusable theme|did not report/i.test(message) ? "custom.importFailed.badDocument" : "custom.importFailed.other").replace("{0}", message);
|
|
2820
|
+
}
|
|
2687
2821
|
/** Structural equality of the two seeds in one pair. */
|
|
2688
2822
|
function samePair(a, b) {
|
|
2689
2823
|
return a?.light === b?.light && a?.dark === b?.dark;
|
|
@@ -2909,20 +3043,125 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2909
3043
|
}));
|
|
2910
3044
|
}
|
|
2911
3045
|
/**
|
|
3046
|
+
* Lucide `edit` (the pencil in a square), path data verbatim. Row actions are
|
|
3047
|
+
* icon-only, so the glyph has to read as the label the accessible name still
|
|
3048
|
+
* carries.
|
|
3049
|
+
*/
|
|
3050
|
+
function IconEdit(props) {
|
|
3051
|
+
const { size = 14 } = props;
|
|
3052
|
+
return react.createElement("svg", {
|
|
3053
|
+
width: size,
|
|
3054
|
+
height: size,
|
|
3055
|
+
viewBox: "0 0 24 24",
|
|
3056
|
+
fill: "none",
|
|
3057
|
+
stroke: "currentColor",
|
|
3058
|
+
strokeWidth: 2,
|
|
3059
|
+
strokeLinecap: "round",
|
|
3060
|
+
strokeLinejoin: "round",
|
|
3061
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3062
|
+
"aria-hidden": true,
|
|
3063
|
+
focusable: false
|
|
3064
|
+
}, react.createElement("path", { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }), react.createElement("path", { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }));
|
|
3065
|
+
}
|
|
3066
|
+
/** Lucide `trash`, path data verbatim; the row's danger action. */
|
|
3067
|
+
function IconTrash(props) {
|
|
3068
|
+
const { size = 14 } = props;
|
|
3069
|
+
return react.createElement("svg", {
|
|
3070
|
+
width: size,
|
|
3071
|
+
height: size,
|
|
3072
|
+
viewBox: "0 0 24 24",
|
|
3073
|
+
fill: "none",
|
|
3074
|
+
stroke: "currentColor",
|
|
3075
|
+
strokeWidth: 2,
|
|
3076
|
+
strokeLinecap: "round",
|
|
3077
|
+
strokeLinejoin: "round",
|
|
3078
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3079
|
+
"aria-hidden": true,
|
|
3080
|
+
focusable: false
|
|
3081
|
+
}, react.createElement("path", { d: "M3 6h18" }), react.createElement("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), react.createElement("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }), react.createElement("line", {
|
|
3082
|
+
x1: 10,
|
|
3083
|
+
y1: 11,
|
|
3084
|
+
x2: 10,
|
|
3085
|
+
y2: 17
|
|
3086
|
+
}), react.createElement("line", {
|
|
3087
|
+
x1: 14,
|
|
3088
|
+
y1: 11,
|
|
3089
|
+
x2: 14,
|
|
3090
|
+
y2: 17
|
|
3091
|
+
}));
|
|
3092
|
+
}
|
|
3093
|
+
/** Lucide `share`, path data verbatim; sends one theme out as an archive. */
|
|
3094
|
+
function IconShare(props) {
|
|
3095
|
+
const { size = 14 } = props;
|
|
3096
|
+
return react.createElement("svg", {
|
|
3097
|
+
width: size,
|
|
3098
|
+
height: size,
|
|
3099
|
+
viewBox: "0 0 24 24",
|
|
3100
|
+
fill: "none",
|
|
3101
|
+
stroke: "currentColor",
|
|
3102
|
+
strokeWidth: 2,
|
|
3103
|
+
strokeLinecap: "round",
|
|
3104
|
+
strokeLinejoin: "round",
|
|
3105
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3106
|
+
"aria-hidden": true,
|
|
3107
|
+
focusable: false
|
|
3108
|
+
}, react.createElement("path", { d: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" }), react.createElement("polyline", { points: "16 6 12 2 8 6" }), react.createElement("line", {
|
|
3109
|
+
x1: 12,
|
|
3110
|
+
y1: 2,
|
|
3111
|
+
x2: 12,
|
|
3112
|
+
y2: 15
|
|
3113
|
+
}));
|
|
3114
|
+
}
|
|
3115
|
+
/** Lucide `download`, path data verbatim; takes one archive in. */
|
|
3116
|
+
function IconDownload(props) {
|
|
3117
|
+
const { size = 14 } = props;
|
|
3118
|
+
return react.createElement("svg", {
|
|
3119
|
+
width: size,
|
|
3120
|
+
height: size,
|
|
3121
|
+
viewBox: "0 0 24 24",
|
|
3122
|
+
fill: "none",
|
|
3123
|
+
stroke: "currentColor",
|
|
3124
|
+
strokeWidth: 2,
|
|
3125
|
+
strokeLinecap: "round",
|
|
3126
|
+
strokeLinejoin: "round",
|
|
3127
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
3128
|
+
"aria-hidden": true,
|
|
3129
|
+
focusable: false
|
|
3130
|
+
}, react.createElement("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), react.createElement("polyline", { points: "7 10 12 15 17 10" }), react.createElement("line", {
|
|
3131
|
+
x1: 12,
|
|
3132
|
+
y1: 15,
|
|
3133
|
+
x2: 12,
|
|
3134
|
+
y2: 3
|
|
3135
|
+
}));
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
2912
3138
|
* One collapsed saved-theme card: the name activates the theme, and the trailing
|
|
2913
|
-
* controls open the editor or delete it. The card as a whole is a
|
|
2914
|
-
* too, but its controls own their clicks.
|
|
3139
|
+
* controls export it, open the editor or delete it. The card as a whole is a
|
|
3140
|
+
* click target too, but its controls own their clicks.
|
|
2915
3141
|
*/
|
|
2916
3142
|
function ThemeCard(props) {
|
|
2917
|
-
const { entry, active, disabled, t, onActivate, onEdit, onDelete } = props;
|
|
2918
|
-
/**
|
|
2919
|
-
|
|
3143
|
+
const { entry, active, disabled, t, onActivate, onExport, onEdit, onDelete } = props;
|
|
3144
|
+
/**
|
|
3145
|
+
* One square icon action; `danger` is the tinted delete variant. The glyph is
|
|
3146
|
+
* decorative, so the label travels as the accessible name instead of as text,
|
|
3147
|
+
* and as `data-tip` for the hover tooltip. `leave` marks the button on the
|
|
3148
|
+
* markup the outside-click guard watches (see the guard's own comment).
|
|
3149
|
+
*/
|
|
3150
|
+
const action = (label, icon, onClick, variant) => react.createElement("button", {
|
|
2920
3151
|
type: "button",
|
|
2921
|
-
className: danger ? "ct-list-btn ct-list-btn-danger" : "ct-list-btn",
|
|
3152
|
+
className: variant === "danger" ? "ct-list-btn ct-list-btn-danger ct-tip" : "ct-list-btn ct-tip",
|
|
3153
|
+
"aria-label": label,
|
|
3154
|
+
"data-tip": label,
|
|
3155
|
+
...variant === "leave" ? {
|
|
3156
|
+
"data-ct-leave": "true",
|
|
3157
|
+
"data-ct-entry": entry.id
|
|
3158
|
+
} : {},
|
|
2922
3159
|
onClick
|
|
2923
|
-
},
|
|
3160
|
+
}, icon);
|
|
2924
3161
|
return react.createElement("div", {
|
|
2925
3162
|
className: "ct-list-row",
|
|
3163
|
+
"data-ct-leave": "true",
|
|
3164
|
+
"data-ct-entry": entry.id,
|
|
2926
3165
|
onClick: disabled ? void 0 : onActivate
|
|
2927
3166
|
}, react.createElement("span", { className: "ct-list-identity" }, react.createElement("button", {
|
|
2928
3167
|
className: "ct-list-name",
|
|
@@ -2935,7 +3174,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2935
3174
|
}, entry.name)), react.createElement("div", {
|
|
2936
3175
|
className: "ct-list-actions",
|
|
2937
3176
|
onClick: (e) => e.stopPropagation()
|
|
2938
|
-
}, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), onEdit), action(t("custom.delete"), onDelete,
|
|
3177
|
+
}, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), react.createElement(IconEdit, { size: 14 }), onEdit, "leave"), action(t("custom.export"), react.createElement(IconShare, { size: 14 }), onExport), action(t("custom.delete"), react.createElement(IconTrash, { size: 14 }), onDelete, "danger")));
|
|
2939
3178
|
}
|
|
2940
3179
|
/**
|
|
2941
3180
|
* Which appearance the colour dots edit. `system` resolves through the same
|
|
@@ -2971,9 +3210,12 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2971
3210
|
const [list, setList] = react.useState(() => saved.list());
|
|
2972
3211
|
const [activeId, setActiveId] = react.useState(() => saved.activeId());
|
|
2973
3212
|
const [pendingDelete, setPendingDelete] = react.useState(null);
|
|
3213
|
+
const [importing, setImporting] = react.useState(false);
|
|
3214
|
+
/** The hidden picker the import button opens. */
|
|
3215
|
+
const importInputRef = react.useRef(null);
|
|
2974
3216
|
const [editing, setEditing] = react.useState(null);
|
|
2975
3217
|
const [confirmClose, setConfirmClose] = react.useState(false);
|
|
2976
|
-
const [
|
|
3218
|
+
const [pendingLeave, setPendingLeave] = react.useState(null);
|
|
2977
3219
|
const editingRef = react.useRef(null);
|
|
2978
3220
|
editingRef.current = editing;
|
|
2979
3221
|
const savingRef = react.useRef(false);
|
|
@@ -2983,8 +3225,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
2983
3225
|
dirtyRef.current = editing !== null && isDirty(editing, custom);
|
|
2984
3226
|
const confirmCloseRef = react.useRef(false);
|
|
2985
3227
|
confirmCloseRef.current = confirmClose;
|
|
2986
|
-
const
|
|
2987
|
-
|
|
3228
|
+
const pendingLeaveRef = react.useRef(null);
|
|
3229
|
+
pendingLeaveRef.current = pendingLeave;
|
|
3230
|
+
const listRef = react.useRef(list);
|
|
3231
|
+
listRef.current = list;
|
|
2988
3232
|
const pendingDeleteRef = react.useRef(null);
|
|
2989
3233
|
pendingDeleteRef.current = pendingDelete;
|
|
2990
3234
|
const [toast, setToast] = react.useState(null);
|
|
@@ -3021,6 +3265,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3021
3265
|
return null;
|
|
3022
3266
|
}
|
|
3023
3267
|
}
|
|
3268
|
+
/** Load a card: the store moves the active pointer itself, so `syncSaved`
|
|
3269
|
+
* re-reads the badge rather than a separate `setActive` call. */
|
|
3024
3270
|
function onLoadSaved(id) {
|
|
3025
3271
|
const next = saved.load(id);
|
|
3026
3272
|
if (next) setCustomState(next);
|
|
@@ -3101,13 +3347,25 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3101
3347
|
function settingsPanel() {
|
|
3102
3348
|
return document.querySelector("[data-slot=\"settings.close\"]")?.closest("[role=\"dialog\"]") ?? null;
|
|
3103
3349
|
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Whether `node` sits on a nav *cell* — the buttons that switch sections —
|
|
3352
|
+
* rather than anywhere else in the rail. Cells are the only buttons in the
|
|
3353
|
+
* nav, and the shell marks the active one with `aria-current`; taking that
|
|
3354
|
+
* button's parent gives the cell list itself, which still matches once the
|
|
3355
|
+
* active cell moves to whatever was just clicked.
|
|
3356
|
+
*/
|
|
3357
|
+
function isNavCell(panel, node) {
|
|
3358
|
+
const list = panel?.querySelector("nav button[aria-current]")?.parentElement;
|
|
3359
|
+
const button = node.closest("button");
|
|
3360
|
+
return !!list && !!button && list.contains(button);
|
|
3361
|
+
}
|
|
3104
3362
|
function onKeyDown(e) {
|
|
3105
3363
|
if (e.key !== "Escape") return;
|
|
3106
|
-
if (confirmCloseRef.current ||
|
|
3364
|
+
if (confirmCloseRef.current || pendingLeaveRef.current) {
|
|
3107
3365
|
e.preventDefault();
|
|
3108
3366
|
e.stopPropagation();
|
|
3109
3367
|
setConfirmClose(false);
|
|
3110
|
-
|
|
3368
|
+
setPendingLeave(null);
|
|
3111
3369
|
return;
|
|
3112
3370
|
}
|
|
3113
3371
|
if (!dirtyRef.current || !onRequestClose) return;
|
|
@@ -3116,7 +3374,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3116
3374
|
setConfirmClose(true);
|
|
3117
3375
|
}
|
|
3118
3376
|
function onClick(e) {
|
|
3119
|
-
if (!dirtyRef.current || confirmCloseRef.current ||
|
|
3377
|
+
if (!dirtyRef.current || confirmCloseRef.current || pendingLeaveRef.current || pendingDeleteRef.current) return;
|
|
3120
3378
|
const target = e.target instanceof Element ? e.target : null;
|
|
3121
3379
|
if (!target) return;
|
|
3122
3380
|
if (target.closest("[data-ct-editor]")) return;
|
|
@@ -3129,9 +3387,23 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3129
3387
|
return;
|
|
3130
3388
|
}
|
|
3131
3389
|
if (!panel || !panel.contains(target)) return;
|
|
3390
|
+
if (isNavCell(panel, target)) {
|
|
3391
|
+
if (!onRequestClose) return;
|
|
3392
|
+
e.preventDefault();
|
|
3393
|
+
e.stopPropagation();
|
|
3394
|
+
setConfirmClose(true);
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const leave = target.closest("[data-ct-leave]");
|
|
3398
|
+
if (!leave) return;
|
|
3399
|
+
const entry = listRef.current.find((item) => item.id === leave.getAttribute("data-ct-entry"));
|
|
3400
|
+
if (!entry) return;
|
|
3132
3401
|
e.preventDefault();
|
|
3133
3402
|
e.stopPropagation();
|
|
3134
|
-
|
|
3403
|
+
setPendingLeave({
|
|
3404
|
+
entry,
|
|
3405
|
+
edit: !!target.closest("button[data-ct-leave]")
|
|
3406
|
+
});
|
|
3135
3407
|
}
|
|
3136
3408
|
document.addEventListener("keydown", onKeyDown, true);
|
|
3137
3409
|
document.addEventListener("click", onClick, true);
|
|
@@ -3161,6 +3433,61 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3161
3433
|
setEditing(null);
|
|
3162
3434
|
syncSaved();
|
|
3163
3435
|
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Send one card out as an archive. Export never touches the draft or the
|
|
3438
|
+
* roster, so it stays available while an editor is open and needs no guard.
|
|
3439
|
+
*/
|
|
3440
|
+
async function exportCard(entry) {
|
|
3441
|
+
try {
|
|
3442
|
+
await exportTheme(entry.id, entry.name);
|
|
3443
|
+
showToast(t("custom.toast.exported").replace("{0}", entry.name));
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
showToast(t(error instanceof ThemeApiUnavailableError ? "custom.toast.unavailable" : "custom.toast.failed"));
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Take one archive in. The Host validates the archive and writes the theme it
|
|
3450
|
+
* carried — a theme is a document, so an import stores exactly what an export
|
|
3451
|
+
* packed, with the Host choosing the id and a free name. The draft is never
|
|
3452
|
+
* involved, so importing leaves an open editor exactly as it was.
|
|
3453
|
+
*/
|
|
3454
|
+
async function importArchive(file) {
|
|
3455
|
+
setImporting(true);
|
|
3456
|
+
try {
|
|
3457
|
+
const imported = await importTheme(file);
|
|
3458
|
+
await reloadRoster();
|
|
3459
|
+
showToast(t("custom.toast.imported").replace("{0}", imported.name));
|
|
3460
|
+
} catch (error) {
|
|
3461
|
+
showToast(importFailureMessage(t, error));
|
|
3462
|
+
} finally {
|
|
3463
|
+
setImporting(false);
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Re-read the roster after a write the store did not make. The import route is
|
|
3468
|
+
* the Host's own: the cached list still holds the pre-import roster, so without
|
|
3469
|
+
* this the new card only appears once some later mutation refreshes the cache.
|
|
3470
|
+
* A failed re-read is not a failed import — the theme is stored either way — so
|
|
3471
|
+
* the card is left to the next roster mutation rather than reported as an error.
|
|
3472
|
+
*/
|
|
3473
|
+
async function reloadRoster() {
|
|
3474
|
+
try {
|
|
3475
|
+
await saved.refresh();
|
|
3476
|
+
syncSaved();
|
|
3477
|
+
} catch {}
|
|
3478
|
+
}
|
|
3479
|
+
/** Open the picker, clearing the previous choice so the same file can be re-picked. */
|
|
3480
|
+
function startImport() {
|
|
3481
|
+
const input = importInputRef.current;
|
|
3482
|
+
if (!input) return;
|
|
3483
|
+
input.value = "";
|
|
3484
|
+
input.click();
|
|
3485
|
+
}
|
|
3486
|
+
function onImportPicked(e) {
|
|
3487
|
+
const file = e.target.files?.[0];
|
|
3488
|
+
e.target.value = "";
|
|
3489
|
+
if (file) importArchive(file);
|
|
3490
|
+
}
|
|
3164
3491
|
/** Commit the open editor: create a new entry, or update the one being edited. */
|
|
3165
3492
|
async function saveEdit() {
|
|
3166
3493
|
if (!editing) return;
|
|
@@ -3279,7 +3606,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3279
3606
|
key
|
|
3280
3607
|
}, react.createElement("div", { className: "ct-seed-label" }, label), react.createElement("div", { className: "ct-seed-dots" }, ...dots.map((d) => react.createElement("label", {
|
|
3281
3608
|
key: d.key,
|
|
3282
|
-
className: "ct-seed-dot",
|
|
3609
|
+
className: "ct-seed-dot ct-tip",
|
|
3283
3610
|
style: { background: d.value },
|
|
3284
3611
|
"data-tip": d.name
|
|
3285
3612
|
}, react.createElement("input", {
|
|
@@ -3397,6 +3724,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3397
3724
|
disabled: editing !== null,
|
|
3398
3725
|
t,
|
|
3399
3726
|
onActivate: () => onLoadSaved(entry.id),
|
|
3727
|
+
onExport: () => void exportCard(entry),
|
|
3400
3728
|
onEdit: () => startEdit(entry),
|
|
3401
3729
|
onDelete: () => setPendingDelete(entry)
|
|
3402
3730
|
}));
|
|
@@ -3404,13 +3732,29 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3404
3732
|
children.push(react.createElement("div", {
|
|
3405
3733
|
className: "ct-list",
|
|
3406
3734
|
key: "saved-list"
|
|
3407
|
-
}, ...cards), react.createElement("
|
|
3735
|
+
}, ...cards), react.createElement("div", {
|
|
3736
|
+
className: "ct-add-row",
|
|
3737
|
+
key: "add-row"
|
|
3738
|
+
}, react.createElement("button", {
|
|
3739
|
+
key: "import",
|
|
3740
|
+
type: "button",
|
|
3741
|
+
className: "ct-add-btn",
|
|
3742
|
+
disabled: importing,
|
|
3743
|
+
onClick: startImport
|
|
3744
|
+
}, react.createElement(IconDownload, null), t("custom.import")), react.createElement("button", {
|
|
3408
3745
|
key: "add",
|
|
3409
3746
|
type: "button",
|
|
3410
3747
|
className: "ct-add-btn",
|
|
3411
3748
|
disabled: editing !== null,
|
|
3412
3749
|
onClick: startAdd
|
|
3413
|
-
}, react.createElement(IconPlus, null), t("custom.add"))
|
|
3750
|
+
}, react.createElement(IconPlus, null), t("custom.add")), react.createElement("input", {
|
|
3751
|
+
key: "import-input",
|
|
3752
|
+
ref: importInputRef,
|
|
3753
|
+
type: "file",
|
|
3754
|
+
accept: `${THEME_ARCHIVE_EXTENSION},application/zip`,
|
|
3755
|
+
style: { display: "none" },
|
|
3756
|
+
onChange: onImportPicked
|
|
3757
|
+
})));
|
|
3414
3758
|
}
|
|
3415
3759
|
if (pendingDelete) children.push(react.createElement(ConfirmDialog, {
|
|
3416
3760
|
key: "confirm-delete",
|
|
@@ -3433,17 +3777,22 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3433
3777
|
},
|
|
3434
3778
|
onCancel: () => setConfirmClose(false)
|
|
3435
3779
|
}));
|
|
3436
|
-
if (
|
|
3780
|
+
if (pendingLeave && editing) children.push(react.createElement(ConfirmDialog, {
|
|
3437
3781
|
key: "confirm-leave",
|
|
3438
3782
|
title: t("custom.unsaved.title"),
|
|
3439
3783
|
description: t("custom.unsaved.leaveDesc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
|
|
3440
3784
|
confirmLabel: t("custom.unsaved.leaveConfirm"),
|
|
3441
3785
|
cancelLabel: t("custom.unsaved.cancel"),
|
|
3442
3786
|
onConfirm: () => {
|
|
3443
|
-
|
|
3787
|
+
const next = pendingLeave;
|
|
3788
|
+
setPendingLeave(null);
|
|
3444
3789
|
cancelEdit();
|
|
3790
|
+
if (next.entry) {
|
|
3791
|
+
if (next.edit) startEdit(next.entry);
|
|
3792
|
+
else onLoadSaved(next.entry.id);
|
|
3793
|
+
}
|
|
3445
3794
|
},
|
|
3446
|
-
onCancel: () =>
|
|
3795
|
+
onCancel: () => setPendingLeave(null)
|
|
3447
3796
|
}));
|
|
3448
3797
|
if (toast) {
|
|
3449
3798
|
const onDone = () => setToast((cur) => cur?.seq === toast.seq ? null : cur);
|
|
@@ -3509,6 +3858,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3509
3858
|
"custom.save": "保存",
|
|
3510
3859
|
"custom.add": "添加自定义主题",
|
|
3511
3860
|
"custom.edit": "编辑",
|
|
3861
|
+
"custom.export": "导出",
|
|
3862
|
+
"custom.import": "导入主题",
|
|
3512
3863
|
"custom.name.placeholder": "主题名称",
|
|
3513
3864
|
"custom.defaultName": "自定义主题",
|
|
3514
3865
|
"custom.inUse": "使用中",
|
|
@@ -3517,6 +3868,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3517
3868
|
"custom.toast.saved": "已保存",
|
|
3518
3869
|
"custom.toast.failed": "主题文件操作失败",
|
|
3519
3870
|
"custom.toast.unavailable": "主题文件接口未就绪,请重启 DSH 服务",
|
|
3871
|
+
"custom.toast.exported": "已导出「{0}」",
|
|
3872
|
+
"custom.toast.imported": "已导入「{0}」",
|
|
3873
|
+
"custom.importFailed.notArchive": "导入失败:不是主题压缩包",
|
|
3874
|
+
"custom.importFailed.noDocument": "导入失败:压缩包缺少 theme.json",
|
|
3875
|
+
"custom.importFailed.badDocument": "导入失败:theme.json 不是有效的主题文件",
|
|
3876
|
+
"custom.importFailed.tooNew": "导入失败:主题文件版本比当前插件更新",
|
|
3877
|
+
"custom.importFailed.media": "导入失败:主题包含媒体文件,暂不支持导入",
|
|
3878
|
+
"custom.importFailed.tooLarge": "导入失败:压缩包超过大小上限",
|
|
3879
|
+
"custom.importFailed.other": "导入失败:{0}",
|
|
3520
3880
|
"custom.delete.title": "删除该自定义主题?",
|
|
3521
3881
|
"custom.delete.desc": "将删除「{0}」,且无法恢复。",
|
|
3522
3882
|
"custom.unsaved.title": "放弃未保存的修改?",
|
|
@@ -3571,6 +3931,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3571
3931
|
"custom.save": "Save",
|
|
3572
3932
|
"custom.add": "Add custom theme",
|
|
3573
3933
|
"custom.edit": "Edit",
|
|
3934
|
+
"custom.export": "Export",
|
|
3935
|
+
"custom.import": "Import theme",
|
|
3574
3936
|
"custom.name.placeholder": "Theme name",
|
|
3575
3937
|
"custom.defaultName": "Custom theme",
|
|
3576
3938
|
"custom.inUse": "In use",
|
|
@@ -3579,6 +3941,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3579
3941
|
"custom.toast.saved": "Saved",
|
|
3580
3942
|
"custom.toast.failed": "Theme file operation failed",
|
|
3581
3943
|
"custom.toast.unavailable": "Theme file API not ready — restart the DSH service",
|
|
3944
|
+
"custom.toast.exported": "Exported \"{0}\"",
|
|
3945
|
+
"custom.toast.imported": "Imported \"{0}\"",
|
|
3946
|
+
"custom.importFailed.notArchive": "Import failed: not a theme archive",
|
|
3947
|
+
"custom.importFailed.noDocument": "Import failed: the archive has no theme.json",
|
|
3948
|
+
"custom.importFailed.badDocument": "Import failed: theme.json is not a usable theme",
|
|
3949
|
+
"custom.importFailed.tooNew": "Import failed: the theme file is newer than this plugin reads",
|
|
3950
|
+
"custom.importFailed.media": "Import failed: the theme carries media, which cannot be imported yet",
|
|
3951
|
+
"custom.importFailed.tooLarge": "Import failed: the archive is over the size limit",
|
|
3952
|
+
"custom.importFailed.other": "Import failed: {0}",
|
|
3582
3953
|
"custom.delete.title": "Delete this custom theme?",
|
|
3583
3954
|
"custom.delete.desc": "\"{0}\" will be removed. This cannot be undone.",
|
|
3584
3955
|
"custom.unsaved.title": "Discard unsaved changes?",
|
|
@@ -3632,8 +4003,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3632
4003
|
".ct-seed-dot{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:26px;height:26px;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;cursor:pointer;}",
|
|
3633
4004
|
".ct-seed-input{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;padding:0;border:none;background:transparent;opacity:0;cursor:pointer;}",
|
|
3634
4005
|
".ct-seed-dot:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
3635
|
-
".ct-
|
|
3636
|
-
".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;}",
|
|
4006
|
+
".ct-tip::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);z-index:100;box-sizing:border-box;width:max-content;max-width:220px;padding:3px 7px;border-radius:8px;background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;font-weight:400;line-height:20px;white-space:pre-line;overflow-wrap:break-word;text-align:left;pointer-events:none;opacity:0;visibility:hidden;transition:opacity 120ms ease;}",
|
|
4007
|
+
".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;visibility:visible;}",
|
|
4008
|
+
".ct-list-btn:hover::after,.ct-list-btn:focus-visible::after{opacity:1;visibility:visible;}",
|
|
4009
|
+
"@media (prefers-reduced-motion: reduce){.ct-tip::after{transition:none;}}",
|
|
3637
4010
|
".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
|
|
3638
4011
|
".ct-list-row{display:flex;align-items:center;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;cursor:pointer;transition:border-color .16s ease,background .16s ease;}",
|
|
3639
4012
|
".ct-list-row:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
@@ -3642,11 +4015,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3642
4015
|
".ct-list-name:disabled{cursor:default;}",
|
|
3643
4016
|
".ct-list-actions{display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto;}",
|
|
3644
4017
|
".ct-list-badge{display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:14px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));font-size:12px;line-height:18px;white-space:nowrap;}",
|
|
3645
|
-
".ct-list-
|
|
4018
|
+
".ct-list-badge + .ct-list-btn{margin-left:8px;}",
|
|
4019
|
+
".ct-list-btn{position:relative;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;width:28px;padding:0;border:none;border-radius:14px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;line-height:0;}",
|
|
3646
4020
|
".ct-list-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
4021
|
+
".ct-list-btn:active{background:var(--dsw-alias-interactive-bg-active);}",
|
|
3647
4022
|
".ct-list-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
3648
|
-
".ct-list-btn
|
|
4023
|
+
".ct-list-btn:disabled{opacity:.4;cursor:not-allowed;}",
|
|
4024
|
+
".ct-list-btn:disabled:hover{background:transparent;}",
|
|
4025
|
+
".ct-list-btn-danger{color:var(--dsw-alias-state-error-primary);}",
|
|
3649
4026
|
".ct-list-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}",
|
|
4027
|
+
".ct-list-btn-danger:active{background:var(--dsw-alias-interactive-bg-hover-danger);}",
|
|
3650
4028
|
".ct-editor{padding:16px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;display:flex;flex-direction:column;gap:0;}",
|
|
3651
4029
|
".ct-editor-head{display:flex;align-items:center;gap:10px;padding-bottom:12px;}",
|
|
3652
4030
|
".ct-editor-name{flex:1 1 auto;min-width:0;box-sizing:border-box;height:36px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;}",
|
|
@@ -3663,6 +4041,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3663
4041
|
".ct-add-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
|
|
3664
4042
|
".ct-add-btn:disabled{opacity:.4;cursor:default;}",
|
|
3665
4043
|
".ct-add-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
|
|
4044
|
+
".ct-add-row{display:flex;align-items:stretch;gap:8px;margin-top:8px;}",
|
|
4045
|
+
".ct-add-row .ct-add-btn{flex:1 1 0;width:auto;margin-top:0;min-width:0;}",
|
|
3666
4046
|
".ct-modal-root{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;}",
|
|
3667
4047
|
".ct-modal-mask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));}",
|
|
3668
4048
|
".ct-modal-card{position:relative;z-index:1;display:flex;flex-direction:column;gap:12px;width:min(380px,100%);padding:24px;border-radius:24px;background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);}",
|
|
@@ -3778,11 +4158,20 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3778
4158
|
lastPreset = base;
|
|
3779
4159
|
writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(base, loadCustom().theme));
|
|
3780
4160
|
}
|
|
4161
|
+
/**
|
|
4162
|
+
* Install a palette. The override layer for our source is replaced in place
|
|
4163
|
+
* rather than torn down first: `theme.overrideTokens` restacks the same source
|
|
4164
|
+
* atomically, while disposing the old layer emits an extra `theme/change` with
|
|
4165
|
+
* our variables missing. The shell's presenter answers that by removing every
|
|
4166
|
+
* token from `body`, so the whole page falls back to the base palette and
|
|
4167
|
+
* repaints before the new layer lands — twice the DOM work and a visible
|
|
4168
|
+
* flash per change, which is exactly what a dragged colour slider produces.
|
|
4169
|
+
*/
|
|
3781
4170
|
function apply(src) {
|
|
3782
|
-
overrideDispose = release(overrideDispose);
|
|
3783
|
-
fallbackDispose = release(fallbackDispose);
|
|
3784
4171
|
const target = src === CUSTOM_SELECTION ? buildCustomPreset(loadCustom().theme) : src;
|
|
3785
4172
|
if (typeof target === "string" && NOOP_PRESET_IDS.has(target)) {
|
|
4173
|
+
overrideDispose = release(overrideDispose);
|
|
4174
|
+
fallbackDispose = release(fallbackDispose);
|
|
3786
4175
|
ensureBaseline(false);
|
|
3787
4176
|
return;
|
|
3788
4177
|
}
|
|
@@ -3790,23 +4179,76 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3790
4179
|
const overrides = buildOverrides(target);
|
|
3791
4180
|
if (theme?.overrideTokens) try {
|
|
3792
4181
|
overrideDispose = theme.overrideTokens("dsh-cool-theme", overrides);
|
|
4182
|
+
fallbackDispose = release(fallbackDispose);
|
|
3793
4183
|
return;
|
|
3794
4184
|
} catch {}
|
|
4185
|
+
overrideDispose = release(overrideDispose);
|
|
4186
|
+
fallbackDispose = release(fallbackDispose);
|
|
3795
4187
|
fallbackDispose = injector.insert(buildFullCssFallback(target));
|
|
3796
4188
|
}
|
|
4189
|
+
/**
|
|
4190
|
+
* Coalesce a burst of palette applications into one.
|
|
4191
|
+
*
|
|
4192
|
+
* A native colour input fires `change` on every pointer move while its slider
|
|
4193
|
+
* is dragged. Each application rebuilds both ramps, rewrites every token on
|
|
4194
|
+
* `body` and forces a style flush in the shell's presenter, so applying once
|
|
4195
|
+
* per event is what makes the colour editor stutter. The first change lands
|
|
4196
|
+
* immediately and the rest are throttled to one per interval, newest request
|
|
4197
|
+
* winning: the palette the pointer is released on is always the one applied.
|
|
4198
|
+
*/
|
|
4199
|
+
const APPLY_INTERVAL_MS = 80;
|
|
4200
|
+
let pendingApply = null;
|
|
4201
|
+
let applyTimer = null;
|
|
4202
|
+
let lastApplyAt = 0;
|
|
4203
|
+
let disposed = false;
|
|
4204
|
+
/** Run the newest pending application now, if any. */
|
|
4205
|
+
function flushApply() {
|
|
4206
|
+
if (applyTimer !== null) {
|
|
4207
|
+
clearTimeout(applyTimer);
|
|
4208
|
+
applyTimer = null;
|
|
4209
|
+
}
|
|
4210
|
+
const run = pendingApply;
|
|
4211
|
+
pendingApply = null;
|
|
4212
|
+
if (!run) return;
|
|
4213
|
+
lastApplyAt = Date.now();
|
|
4214
|
+
run();
|
|
4215
|
+
}
|
|
4216
|
+
/** Drop a coalesced application, for a fiber that is going away. */
|
|
4217
|
+
function cancelApply() {
|
|
4218
|
+
if (applyTimer !== null) {
|
|
4219
|
+
clearTimeout(applyTimer);
|
|
4220
|
+
applyTimer = null;
|
|
4221
|
+
}
|
|
4222
|
+
pendingApply = null;
|
|
4223
|
+
}
|
|
4224
|
+
/**
|
|
4225
|
+
* Apply a palette, at most once per interval while a burst of edits is
|
|
4226
|
+
* running. The callback is deferred whole, so a dropped intermediate edit
|
|
4227
|
+
* never even rebuilds its ramps; only the newest one is executed.
|
|
4228
|
+
*/
|
|
4229
|
+
function scheduleApply(run) {
|
|
4230
|
+
if (disposed) return;
|
|
4231
|
+
pendingApply = run;
|
|
4232
|
+
const elapsed = Date.now() - lastApplyAt;
|
|
4233
|
+
if (elapsed >= APPLY_INTERVAL_MS) {
|
|
4234
|
+
flushApply();
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (applyTimer === null) applyTimer = setTimeout(flushApply, APPLY_INTERVAL_MS - elapsed);
|
|
4238
|
+
}
|
|
3797
4239
|
function setSelection(id) {
|
|
3798
4240
|
writeRaw(THEME_STORAGE_KEY, id);
|
|
3799
4241
|
if (id !== CUSTOM_SELECTION) lastPreset = id;
|
|
3800
|
-
apply(id);
|
|
4242
|
+
scheduleApply(() => apply(id));
|
|
3801
4243
|
}
|
|
3802
4244
|
function setCustom(next) {
|
|
3803
4245
|
persistCustom(next);
|
|
3804
|
-
apply(buildCustomPreset(next));
|
|
4246
|
+
scheduleApply(() => apply(buildCustomPreset(next)));
|
|
3805
4247
|
}
|
|
3806
4248
|
function resetCustom() {
|
|
3807
4249
|
const theme = extractSeeds(presetDef(customBase()));
|
|
3808
4250
|
persistCustom(theme);
|
|
3809
|
-
apply(buildCustomPreset(theme));
|
|
4251
|
+
scheduleApply(() => apply(buildCustomPreset(theme)));
|
|
3810
4252
|
return theme;
|
|
3811
4253
|
}
|
|
3812
4254
|
const fallbackForBase = (base) => extractSeeds(presetDef(isPresetId(base) ? base : lastPreset));
|
|
@@ -3849,7 +4291,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3849
4291
|
if (isPresetId(entry.base)) lastPreset = entry.base;
|
|
3850
4292
|
writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
|
|
3851
4293
|
writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(entry.base, entry.theme));
|
|
3852
|
-
apply(buildCustomPreset(entry.theme));
|
|
4294
|
+
scheduleApply(() => apply(buildCustomPreset(entry.theme)));
|
|
3853
4295
|
return entry.theme;
|
|
3854
4296
|
}
|
|
3855
4297
|
const saved = {
|
|
@@ -3911,6 +4353,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
|
|
|
3911
4353
|
}).catch(() => {});
|
|
3912
4354
|
} catch {}
|
|
3913
4355
|
ctx.effect(() => () => {
|
|
4356
|
+
disposed = true;
|
|
4357
|
+
cancelApply();
|
|
3914
4358
|
release(pluginCssDisposer);
|
|
3915
4359
|
baselineDisposer = release(baselineDisposer);
|
|
3916
4360
|
overrideDispose = release(overrideDispose);
|