@mp-consulting/homebridge-ui-kit 1.2.2 → 1.3.0
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 +294 -37
- package/dist/ai.css +63 -16
- package/dist/ai.min.css +3 -0
- package/dist/ai.min.css.map +1 -0
- package/dist/kit.css +689 -21
- package/dist/kit.d.mts +37 -0
- package/dist/kit.d.ts +513 -0
- package/dist/kit.js +1978 -485
- package/dist/kit.min.css +3 -0
- package/dist/kit.min.css.map +1 -0
- package/dist/kit.min.js +13 -0
- package/dist/kit.min.js.map +1 -0
- package/dist/kit.mjs +2315 -0
- package/dist/theme-boot.js +2 -0
- package/package.json +39 -3
- package/scripts/copy-assets.js +81 -15
package/dist/kit.js
CHANGED
|
@@ -1,302 +1,365 @@
|
|
|
1
|
-
/* @mp-consulting/homebridge-ui-kit v1.
|
|
1
|
+
/* @mp-consulting/homebridge-ui-kit v1.3.0
|
|
2
2
|
Brand design system for Homebridge plugins
|
|
3
3
|
https://github.com/mp-consulting/homebridge-ui-kit */
|
|
4
|
+
(() => {
|
|
5
|
+
// src/js/env.js
|
|
6
|
+
var root = typeof window !== "undefined" ? window : globalThis;
|
|
7
|
+
function doc() {
|
|
8
|
+
return typeof document !== "undefined" ? document : root.document;
|
|
9
|
+
}
|
|
10
|
+
function hbClient() {
|
|
11
|
+
return root.homebridge || (typeof homebridge !== "undefined" ? homebridge : void 0);
|
|
12
|
+
}
|
|
4
13
|
|
|
5
|
-
|
|
6
|
-
'
|
|
7
|
-
|
|
8
|
-
var ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
|
9
|
-
|
|
10
|
-
/** Escapes a value for safe use in HTML text and quoted attributes. */
|
|
14
|
+
// src/js/util.js
|
|
15
|
+
var ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
11
16
|
function escapeHtml(value) {
|
|
12
|
-
return String(value == null ?
|
|
13
|
-
return ESCAPES[c];
|
|
14
|
-
});
|
|
17
|
+
return String(value == null ? "" : value).replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
15
18
|
}
|
|
16
|
-
|
|
17
|
-
/** Returns the URL if it uses http(s), otherwise null (blocks javascript:, data:, …). */
|
|
18
19
|
function safeUrl(url) {
|
|
19
|
-
if (typeof url !==
|
|
20
|
+
if (typeof url !== "string") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
20
23
|
return /^https?:\/\//i.test(url.trim()) ? url.trim() : null;
|
|
21
24
|
}
|
|
25
|
+
var uidSeq = 0;
|
|
26
|
+
function uid(prefix) {
|
|
27
|
+
uidSeq += 1;
|
|
28
|
+
return (prefix || "mp") + "-" + uidSeq;
|
|
29
|
+
}
|
|
30
|
+
function toElement(target) {
|
|
31
|
+
if (!target) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof target === "string") {
|
|
35
|
+
const d = doc();
|
|
36
|
+
return d ? d.querySelector(target) : null;
|
|
37
|
+
}
|
|
38
|
+
return target;
|
|
39
|
+
}
|
|
40
|
+
function errorMessage(err, fallback) {
|
|
41
|
+
if (typeof err === "string" && err) {
|
|
42
|
+
return err;
|
|
43
|
+
}
|
|
44
|
+
return err && err.message || fallback;
|
|
45
|
+
}
|
|
46
|
+
var liveRegion = null;
|
|
47
|
+
function announce(message, politeness) {
|
|
48
|
+
const d = doc();
|
|
49
|
+
if (!d || !d.body || typeof d.createElement !== "function") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!liveRegion || !liveRegion.isConnected) {
|
|
53
|
+
liveRegion = d.createElement("div");
|
|
54
|
+
liveRegion.className = "mp-sr-only";
|
|
55
|
+
liveRegion.setAttribute("data-mp-live", "");
|
|
56
|
+
liveRegion.setAttribute("aria-atomic", "true");
|
|
57
|
+
d.body.appendChild(liveRegion);
|
|
58
|
+
}
|
|
59
|
+
liveRegion.setAttribute("aria-live", politeness === "assertive" ? "assertive" : "polite");
|
|
60
|
+
liveRegion.textContent = "";
|
|
61
|
+
const text = String(message == null ? "" : message);
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
liveRegion.textContent = text;
|
|
64
|
+
}, 30);
|
|
65
|
+
}
|
|
66
|
+
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
67
|
+
function focusables(el) {
|
|
68
|
+
return Array.from(el.querySelectorAll(FOCUSABLE)).filter((n) => !n.hidden && !n.closest("[hidden]"));
|
|
69
|
+
}
|
|
70
|
+
function focusElement(el) {
|
|
71
|
+
if (!el || typeof el.focus !== "function") {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!el.matches(FOCUSABLE) && !el.hasAttribute("tabindex")) {
|
|
75
|
+
el.setAttribute("tabindex", "-1");
|
|
76
|
+
el.classList.add("mp-focus-target");
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
el.focus({ preventScroll: true });
|
|
80
|
+
} catch {
|
|
81
|
+
el.focus();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function textOf(el) {
|
|
85
|
+
return el ? String(el.textContent || "").replace(/\s+/g, " ").trim() : "";
|
|
86
|
+
}
|
|
22
87
|
|
|
88
|
+
// src/js/core.js
|
|
23
89
|
function badge(badgeClass, dotClass, label) {
|
|
24
|
-
return '<span class="badge ' + badgeClass + '">'
|
|
25
|
-
+ (dotClass ? '<span class="mp-status ' + dotClass + ' me-1" aria-hidden="true"></span>' : '')
|
|
26
|
-
+ escapeHtml(label) + '</span>';
|
|
90
|
+
return '<span class="badge ' + badgeClass + '">' + (dotClass ? '<span class="mp-status ' + dotClass + '" aria-hidden="true"></span>' : "") + escapeHtml(label) + "</span>";
|
|
27
91
|
}
|
|
28
|
-
|
|
29
|
-
// Icon paths from Bootstrap Icons 1.11.3 (MIT), inlined so the footer
|
|
30
|
-
// works whether or not the plugin loads the icon font.
|
|
31
92
|
var FOOTER_LINKS = [
|
|
32
93
|
{
|
|
33
|
-
key:
|
|
34
|
-
text:
|
|
94
|
+
key: "github",
|
|
95
|
+
text: "GitHub",
|
|
35
96
|
paths: [
|
|
36
|
-
|
|
37
|
-
]
|
|
97
|
+
"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"
|
|
98
|
+
]
|
|
38
99
|
},
|
|
39
100
|
{
|
|
40
|
-
key:
|
|
41
|
-
text:
|
|
101
|
+
key: "npm",
|
|
102
|
+
text: "npm",
|
|
42
103
|
paths: [
|
|
43
|
-
|
|
44
|
-
]
|
|
104
|
+
"M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5l2.404.961L10.404 2zm3.564 1.426L5.596 5 8 5.961 14.154 3.5zm3.25 1.7-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464z"
|
|
105
|
+
]
|
|
45
106
|
},
|
|
46
107
|
{
|
|
47
|
-
key:
|
|
48
|
-
text:
|
|
108
|
+
key: "changelog",
|
|
109
|
+
text: "Changelog",
|
|
49
110
|
paths: [
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
]
|
|
54
|
-
}
|
|
111
|
+
"M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022zm2.004.45a7 7 0 0 0-.985-.299l.219-.976q.576.129 1.126.342zm1.37.71a7 7 0 0 0-.439-.27l.493-.87a8 8 0 0 1 .979.654l-.615.789a7 7 0 0 0-.418-.302zm1.834 1.79a7 7 0 0 0-.653-.796l.724-.69q.406.429.747.91zm.744 1.352a7 7 0 0 0-.214-.468l.893-.45a8 8 0 0 1 .45 1.088l-.95.313a7 7 0 0 0-.179-.483m.53 2.507a7 7 0 0 0-.1-1.025l.985-.17q.1.58.116 1.17zm-.131 1.538q.05-.254.081-.51l.993.123a8 8 0 0 1-.23 1.155l-.964-.267q.069-.247.12-.501m-.952 2.379q.276-.436.486-.908l.914.405q-.24.54-.555 1.038zm-.964 1.205q.183-.183.35-.378l.758.653a8 8 0 0 1-.401.432z",
|
|
112
|
+
"M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0z",
|
|
113
|
+
"M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5"
|
|
114
|
+
]
|
|
115
|
+
}
|
|
55
116
|
];
|
|
56
|
-
|
|
57
117
|
function icon(paths) {
|
|
58
|
-
return '<svg class="mp-footer-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
|
|
59
|
-
+ paths.map(function (d) { return '<path d="' + d + '"/>'; }).join('')
|
|
60
|
-
+ '</svg>';
|
|
118
|
+
return '<svg class="mp-footer-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">' + paths.map((d) => '<path d="' + d + '"/>').join("") + "</svg>";
|
|
61
119
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* MpKit.View.show('listView') → shows #listView, hides all others
|
|
135
|
-
* MpKit.View.show('settingsView') → shows #settingsView, hides all others
|
|
136
|
-
*/
|
|
137
|
-
View: {
|
|
138
|
-
show: function (id) {
|
|
139
|
-
document.querySelectorAll('.mp-view').forEach(function (v) {
|
|
140
|
-
v.classList.toggle('active', v.id === id);
|
|
141
|
-
});
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Footer — renders support links into a .mp-footer element.
|
|
147
|
-
* Only http(s) URLs are rendered; anything else is skipped.
|
|
148
|
-
*
|
|
149
|
-
* MpKit.Footer.render({
|
|
150
|
-
* github: 'https://github.com/mp-consulting/homebridge-...',
|
|
151
|
-
* npm: 'https://www.npmjs.com/package/@mp-consulting/...',
|
|
152
|
-
* changelog: 'https://github.com/.../blob/main/CHANGELOG.md',
|
|
153
|
-
* target: '.mp-footer', // optional selector or element
|
|
154
|
-
* })
|
|
155
|
-
*/
|
|
156
|
-
Footer: {
|
|
157
|
-
render: function (opts) {
|
|
158
|
-
opts = opts || {};
|
|
159
|
-
var target = opts.target || '.mp-footer';
|
|
160
|
-
var el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
161
|
-
if (!el) { return; }
|
|
162
|
-
var links = [];
|
|
163
|
-
FOOTER_LINKS.forEach(function (link) {
|
|
164
|
-
var url = safeUrl(opts[link.key]);
|
|
165
|
-
if (!url) { return; }
|
|
166
|
-
links.push('<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">'
|
|
167
|
-
+ icon(link.paths) + link.text + '</a>');
|
|
168
|
-
});
|
|
169
|
-
el.innerHTML = links.join('<span class="mp-footer-sep" aria-hidden="true">|</span>');
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
|
|
120
|
+
var StatusBadge = {
|
|
121
|
+
online: (label) => badge("bg-success-subtle text-success-emphasis", "mp-status-online", label || "Online"),
|
|
122
|
+
offline: (label) => badge("bg-danger-subtle text-danger-emphasis", "mp-status-offline", label || "Offline"),
|
|
123
|
+
checking: (label) => badge("bg-secondary-subtle text-secondary-emphasis", "mp-status-checking", label || "Checking…"),
|
|
124
|
+
disabled: (label) => badge("bg-secondary", null, label || "Disabled")
|
|
125
|
+
};
|
|
126
|
+
var EmptyState = {
|
|
127
|
+
render(opts) {
|
|
128
|
+
opts = opts || {};
|
|
129
|
+
const iconClass = opts.iconClass || "bi bi-inbox";
|
|
130
|
+
const title = opts.title || "No items";
|
|
131
|
+
const hint = opts.hint || "";
|
|
132
|
+
return '<div class="mp-empty-state"><i class="' + escapeHtml(iconClass) + ' mp-empty-state-icon" aria-hidden="true"></i><p class="mp-empty-state-title">' + escapeHtml(title) + "</p>" + (hint ? '<p class="mp-empty-state-hint">' + escapeHtml(hint) + "</p>" : "") + "</div>";
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var Loading = {
|
|
136
|
+
render(message) {
|
|
137
|
+
message = message || "Loading…";
|
|
138
|
+
return '<div class="mp-loading" role="status" aria-live="polite"><div class="spinner-border spinner-border-sm text-secondary" aria-hidden="true"></div><span>' + escapeHtml(message) + "</span></div>";
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
var HEADING = "h1, h2, h3, h4, h5, h6, [data-mp-focus]";
|
|
142
|
+
var View = {
|
|
143
|
+
show(id, opts) {
|
|
144
|
+
opts = opts || {};
|
|
145
|
+
let shown = null;
|
|
146
|
+
let changed = false;
|
|
147
|
+
let previous = false;
|
|
148
|
+
doc().querySelectorAll(".mp-view").forEach((v) => {
|
|
149
|
+
const active = v.id === id;
|
|
150
|
+
const was = v.classList.contains("active");
|
|
151
|
+
if (active) {
|
|
152
|
+
shown = v;
|
|
153
|
+
changed = !was;
|
|
154
|
+
} else if (was) {
|
|
155
|
+
previous = true;
|
|
156
|
+
}
|
|
157
|
+
v.classList.toggle("active", active);
|
|
158
|
+
});
|
|
159
|
+
if (!shown || !changed || typeof shown.querySelector !== "function") {
|
|
160
|
+
return shown;
|
|
161
|
+
}
|
|
162
|
+
const heading = shown.querySelector(HEADING);
|
|
163
|
+
if (opts.focus === void 0 ? previous : opts.focus) {
|
|
164
|
+
focusElement(heading || shown);
|
|
165
|
+
}
|
|
166
|
+
if (opts.announce === void 0 ? previous : opts.announce) {
|
|
167
|
+
const label = typeof opts.announce === "string" ? opts.announce : shown.getAttribute("aria-label") || textOf(heading);
|
|
168
|
+
if (label) {
|
|
169
|
+
announce(label);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return shown;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var Footer = {
|
|
176
|
+
render(opts) {
|
|
177
|
+
opts = opts || {};
|
|
178
|
+
const el = toElement(opts.target || ".mp-footer");
|
|
179
|
+
if (!el) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const links = [];
|
|
183
|
+
FOOTER_LINKS.forEach((link) => {
|
|
184
|
+
const url = safeUrl(opts[link.key]);
|
|
185
|
+
if (!url) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
links.push('<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + icon(link.paths) + link.text + "</a>");
|
|
189
|
+
});
|
|
190
|
+
el.innerHTML = links.join('<span class="mp-footer-sep" aria-hidden="true">|</span>');
|
|
191
|
+
}
|
|
173
192
|
};
|
|
174
193
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
// @mp-consulting/homebridge-ai-core/plugin (`registerAiRoutes`) through the global
|
|
178
|
-
// `homebridge` object from @homebridge/plugin-ui-utils.
|
|
179
|
-
|
|
180
|
-
// Generic four-point sparkle (not a product logo).
|
|
181
|
-
var SPARKLE = '<svg class="mp-ai-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
|
|
182
|
-
+ '<path d="M8 0c.4 3.9 2.1 5.6 6 6-3.9.4-5.6 2.1-6 6-.4-3.9-2.1-5.6-6-6 3.9-.4 5.6-2.1 6-6z"/>'
|
|
183
|
-
+ '<path d="M13.5 10.5c.15 1.5.85 2.2 2.5 2.5-1.65.3-2.35 1-2.5 2.5-.15-1.5-.85-2.2-2.5-2.5 1.65-.3 2.35-1 2.5-2.5z"/>'
|
|
184
|
-
+ '</svg>';
|
|
194
|
+
// src/js/ai.js
|
|
195
|
+
var SPARKLE = '<svg class="mp-ai-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M8 0c.4 3.9 2.1 5.6 6 6-3.9.4-5.6 2.1-6 6-.4-3.9-2.1-5.6-6-6 3.9-.4 5.6-2.1 6-6z"/><path d="M13.5 10.5c.15 1.5.85 2.2 2.5 2.5-1.65.3-2.35 1-2.5 2.5-.15-1.5-.85-2.2-2.5-2.5 1.65-.3 2.35-1 2.5-2.5z"/></svg>';
|
|
185
196
|
var CARET = '<span class="mp-ai-caret" aria-hidden="true"></span>';
|
|
186
|
-
var DEFAULT_NOTE =
|
|
187
|
-
var DEFAULT_ERROR =
|
|
188
|
-
var LCS_LIMIT =
|
|
189
|
-
|
|
197
|
+
var DEFAULT_NOTE = "Generated by the Assistant. Check it before acting on it.";
|
|
198
|
+
var DEFAULT_ERROR = "The Assistant could not answer. Please try again.";
|
|
199
|
+
var LCS_LIMIT = 4e6;
|
|
190
200
|
var aiSeq = 0;
|
|
191
|
-
var uid = 0;
|
|
192
|
-
|
|
193
|
-
function hbClient() {
|
|
194
|
-
return global.homebridge || (typeof homebridge !== 'undefined' ? homebridge : undefined);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
201
|
function newRequestId() {
|
|
198
202
|
aiSeq += 1;
|
|
199
|
-
return
|
|
200
|
-
+ Math.random().toString(36).slice(2, 8);
|
|
203
|
+
return "mpai-" + Date.now().toString(36) + "-" + aiSeq.toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
|
201
204
|
}
|
|
202
|
-
|
|
203
205
|
function eventData(ev) {
|
|
204
|
-
if (ev && ev.data !==
|
|
205
|
-
|
|
206
|
+
if (ev && ev.data !== void 0) {
|
|
207
|
+
return ev.data;
|
|
208
|
+
}
|
|
209
|
+
if (ev && ev.detail !== void 0) {
|
|
210
|
+
return ev.detail;
|
|
211
|
+
}
|
|
206
212
|
return ev;
|
|
207
213
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
function abortError(reason) {
|
|
215
|
+
if (reason != null) {
|
|
216
|
+
return reason;
|
|
217
|
+
}
|
|
218
|
+
var message = "The Assistant request was cancelled";
|
|
219
|
+
if (typeof DOMException === "function") {
|
|
220
|
+
try {
|
|
221
|
+
return new DOMException(message, "AbortError");
|
|
222
|
+
} catch (e) {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
var err = new Error(message);
|
|
226
|
+
err.name = "AbortError";
|
|
227
|
+
return err;
|
|
228
|
+
}
|
|
229
|
+
function notifyCancel(hb, requestId) {
|
|
230
|
+
try {
|
|
231
|
+
Promise.resolve(hb.request("/ai/cancel", { requestId })).catch(function() {
|
|
232
|
+
});
|
|
233
|
+
} catch (e) {
|
|
234
|
+
}
|
|
212
235
|
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Sends a request to an /ai/* route with a generated requestId and, while it
|
|
216
|
-
* runs, forwards matching `ai:chunk` / `ai:done` / `ai:error` events to the
|
|
217
|
-
* onChunk / onDone / onError callbacks. Resolves with the route's result.
|
|
218
|
-
*/
|
|
219
236
|
function aiRequest(path, body, opts) {
|
|
220
237
|
opts = opts || {};
|
|
221
238
|
var hb = hbClient();
|
|
222
|
-
if (!hb || typeof hb.request !==
|
|
223
|
-
return Promise.reject(new Error(
|
|
239
|
+
if (!hb || typeof hb.request !== "function") {
|
|
240
|
+
return withCancel(Promise.reject(new Error("MpKit.ai needs the Homebridge plugin UI (window.homebridge)")));
|
|
241
|
+
}
|
|
242
|
+
var signal = opts.signal;
|
|
243
|
+
if (signal && signal.aborted) {
|
|
244
|
+
return withCancel(Promise.reject(abortError(signal.reason)));
|
|
224
245
|
}
|
|
225
246
|
var requestId = newRequestId();
|
|
226
247
|
var payload = {};
|
|
227
|
-
Object.keys(body || {}).forEach(function
|
|
248
|
+
Object.keys(body || {}).forEach(function(k) {
|
|
249
|
+
payload[k] = body[k];
|
|
250
|
+
});
|
|
228
251
|
payload.requestId = requestId;
|
|
229
|
-
|
|
252
|
+
var settled = false;
|
|
230
253
|
var listeners = [];
|
|
231
|
-
if (typeof hb.addEventListener ===
|
|
232
|
-
var on = function
|
|
233
|
-
var handler = function
|
|
254
|
+
if (typeof hb.addEventListener === "function") {
|
|
255
|
+
var on = function(type, fn) {
|
|
256
|
+
var handler = function(ev) {
|
|
234
257
|
var data = eventData(ev);
|
|
235
|
-
if (data && data.requestId === requestId) {
|
|
258
|
+
if (!settled && data && data.requestId === requestId) {
|
|
259
|
+
fn(data);
|
|
260
|
+
}
|
|
236
261
|
};
|
|
237
262
|
hb.addEventListener(type, handler);
|
|
238
263
|
listeners.push([type, handler]);
|
|
239
264
|
};
|
|
240
|
-
if (typeof opts.onChunk ===
|
|
241
|
-
on(
|
|
242
|
-
if (typeof d.delta ===
|
|
265
|
+
if (typeof opts.onChunk === "function") {
|
|
266
|
+
on("ai:chunk", function(d) {
|
|
267
|
+
if (typeof d.delta === "string" && d.delta) {
|
|
268
|
+
opts.onChunk(d.delta, d);
|
|
269
|
+
}
|
|
243
270
|
});
|
|
244
271
|
}
|
|
245
|
-
if (typeof opts.onDone ===
|
|
246
|
-
on(
|
|
272
|
+
if (typeof opts.onDone === "function") {
|
|
273
|
+
on("ai:done", function(d) {
|
|
274
|
+
opts.onDone(d);
|
|
275
|
+
});
|
|
247
276
|
}
|
|
248
|
-
if (typeof opts.onError ===
|
|
249
|
-
on(
|
|
277
|
+
if (typeof opts.onError === "function") {
|
|
278
|
+
on("ai:error", function(d) {
|
|
279
|
+
opts.onError(d.message || DEFAULT_ERROR, d);
|
|
280
|
+
});
|
|
250
281
|
}
|
|
251
282
|
}
|
|
283
|
+
var rejectCancelled;
|
|
284
|
+
var cancelled = new Promise(function(resolve, reject) {
|
|
285
|
+
rejectCancelled = reject;
|
|
286
|
+
});
|
|
287
|
+
cancelled.catch(function() {
|
|
288
|
+
});
|
|
289
|
+
function onAbort() {
|
|
290
|
+
cancel(signal.reason);
|
|
291
|
+
}
|
|
252
292
|
function cleanup() {
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
settled = true;
|
|
294
|
+
if (signal && typeof signal.removeEventListener === "function") {
|
|
295
|
+
signal.removeEventListener("abort", onAbort);
|
|
296
|
+
}
|
|
297
|
+
if (typeof hb.removeEventListener !== "function") {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
listeners.forEach(function(l) {
|
|
301
|
+
hb.removeEventListener(l[0], l[1]);
|
|
302
|
+
});
|
|
303
|
+
listeners = [];
|
|
304
|
+
}
|
|
305
|
+
function cancel(reason) {
|
|
306
|
+
if (settled) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
cleanup();
|
|
310
|
+
if (opts.notifyServer !== false) {
|
|
311
|
+
notifyCancel(hb, requestId);
|
|
312
|
+
}
|
|
313
|
+
rejectCancelled(abortError(reason));
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
317
|
+
signal.addEventListener("abort", onAbort);
|
|
255
318
|
}
|
|
256
|
-
|
|
257
319
|
var pending;
|
|
258
320
|
try {
|
|
259
321
|
pending = Promise.resolve(hb.request(path, payload));
|
|
260
322
|
} catch (err) {
|
|
261
323
|
pending = Promise.reject(err);
|
|
262
324
|
}
|
|
263
|
-
|
|
325
|
+
var result = Promise.race([pending, cancelled]).then(function(value) {
|
|
264
326
|
cleanup();
|
|
265
|
-
return
|
|
266
|
-
}, function
|
|
327
|
+
return value;
|
|
328
|
+
}, function(err) {
|
|
267
329
|
cleanup();
|
|
268
330
|
throw err;
|
|
269
331
|
});
|
|
332
|
+
return withCancel(result, cancel, requestId);
|
|
333
|
+
}
|
|
334
|
+
function withCancel(promise, cancel, requestId) {
|
|
335
|
+
promise.cancel = cancel || function() {
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
promise.requestId = requestId || null;
|
|
339
|
+
return promise;
|
|
270
340
|
}
|
|
271
|
-
|
|
272
|
-
// ── Safe markdown subset ──
|
|
273
|
-
// Paragraphs, **bold**, `inline code`, fenced code blocks, bullet and
|
|
274
|
-
// numbered lists, and #-headings (rendered as bold paragraphs). Every piece of
|
|
275
|
-
// text is HTML-escaped before any tag is added, so model output can never
|
|
276
|
-
// inject markup. Links are left as plain text.
|
|
277
|
-
|
|
278
341
|
function inlineMarkdown(text) {
|
|
279
|
-
return String(text).split(/(`[^`\n]+`)/).map(function
|
|
280
|
-
if (i % 2 === 1) {
|
|
281
|
-
|
|
282
|
-
|
|
342
|
+
return String(text).split(/(`[^`\n]+`)/).map(function(part, i) {
|
|
343
|
+
if (i % 2 === 1) {
|
|
344
|
+
return "<code>" + escapeHtml(part.slice(1, -1)) + "</code>";
|
|
345
|
+
}
|
|
346
|
+
return escapeHtml(part).replace(/\*\*([^*\n]+?)\*\*|__([^_\n]+?)__/g, function(m, a, b) {
|
|
347
|
+
return "<strong>" + (a || b) + "</strong>";
|
|
283
348
|
});
|
|
284
|
-
}).join(
|
|
349
|
+
}).join("");
|
|
285
350
|
}
|
|
286
|
-
|
|
287
351
|
var UL_ITEM = /^\s*[-*+]\s+(.*)$/;
|
|
288
352
|
var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
|
|
289
|
-
var
|
|
353
|
+
var HEADING2 = /^\s*#{1,6}\s+(.*)$/;
|
|
290
354
|
var FENCE = /^\s*(```|~~~)/;
|
|
291
|
-
|
|
292
355
|
function markdown(text) {
|
|
293
|
-
var lines = String(text == null ?
|
|
356
|
+
var lines = String(text == null ? "" : text).replace(/\r\n?/g, "\n").split("\n");
|
|
294
357
|
var out = [];
|
|
295
358
|
var para = [];
|
|
296
359
|
var i = 0;
|
|
297
360
|
function flush() {
|
|
298
361
|
if (para.length) {
|
|
299
|
-
out.push(
|
|
362
|
+
out.push("<p>" + para.map(inlineMarkdown).join("<br>") + "</p>");
|
|
300
363
|
para = [];
|
|
301
364
|
}
|
|
302
365
|
}
|
|
@@ -311,26 +374,26 @@
|
|
|
311
374
|
code.push(lines[i]);
|
|
312
375
|
i += 1;
|
|
313
376
|
}
|
|
314
|
-
i += 1;
|
|
315
|
-
out.push(
|
|
377
|
+
i += 1;
|
|
378
|
+
out.push("<pre><code>" + escapeHtml(code.join("\n")) + "</code></pre>");
|
|
316
379
|
continue;
|
|
317
380
|
}
|
|
318
|
-
var listRe = UL_ITEM.test(line) ? UL_ITEM :
|
|
381
|
+
var listRe = UL_ITEM.test(line) ? UL_ITEM : OL_ITEM.test(line) ? OL_ITEM : null;
|
|
319
382
|
if (listRe) {
|
|
320
383
|
flush();
|
|
321
|
-
var tag = listRe === UL_ITEM ?
|
|
384
|
+
var tag = listRe === UL_ITEM ? "ul" : "ol";
|
|
322
385
|
var items = [];
|
|
323
386
|
while (i < lines.length && listRe.test(lines[i])) {
|
|
324
|
-
items.push(
|
|
387
|
+
items.push("<li>" + inlineMarkdown(listRe.exec(lines[i])[1]) + "</li>");
|
|
325
388
|
i += 1;
|
|
326
389
|
}
|
|
327
|
-
out.push(
|
|
390
|
+
out.push("<" + tag + ">" + items.join("") + "</" + tag + ">");
|
|
328
391
|
continue;
|
|
329
392
|
}
|
|
330
|
-
var heading =
|
|
393
|
+
var heading = HEADING2.exec(line);
|
|
331
394
|
if (heading) {
|
|
332
395
|
flush();
|
|
333
|
-
out.push(
|
|
396
|
+
out.push("<p><strong>" + inlineMarkdown(heading[1]) + "</strong></p>");
|
|
334
397
|
} else if (!line.trim()) {
|
|
335
398
|
flush();
|
|
336
399
|
} else {
|
|
@@ -339,34 +402,38 @@
|
|
|
339
402
|
i += 1;
|
|
340
403
|
}
|
|
341
404
|
flush();
|
|
342
|
-
return out.join(
|
|
405
|
+
return out.join("");
|
|
343
406
|
}
|
|
344
|
-
|
|
345
|
-
/** Puts the streaming caret at the end of the last paragraph or list item. */
|
|
346
407
|
function withCaret(html) {
|
|
347
|
-
if (/<\/p>$/.test(html)) {
|
|
348
|
-
|
|
408
|
+
if (/<\/p>$/.test(html)) {
|
|
409
|
+
return html.slice(0, -4) + CARET + "</p>";
|
|
410
|
+
}
|
|
411
|
+
if (/<\/li><\/[ou]l>$/.test(html)) {
|
|
412
|
+
return html.slice(0, -10) + CARET + html.slice(-10);
|
|
413
|
+
}
|
|
349
414
|
return html + CARET;
|
|
350
415
|
}
|
|
351
|
-
|
|
352
|
-
// ── Line diff (longest common subsequence) ──
|
|
353
|
-
|
|
354
416
|
function toLines(value) {
|
|
355
|
-
if (value == null) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
417
|
+
if (value == null) {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
var text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
421
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
422
|
+
if (text === "") {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
if (text.charAt(text.length - 1) === "\n") {
|
|
426
|
+
text = text.slice(0, -1);
|
|
427
|
+
}
|
|
428
|
+
return text.split("\n");
|
|
361
429
|
}
|
|
362
|
-
|
|
363
430
|
function diffLines(before, after) {
|
|
364
431
|
var a = toLines(before);
|
|
365
432
|
var b = toLines(after);
|
|
366
433
|
var out = [];
|
|
367
434
|
var start = 0;
|
|
368
435
|
while (start < a.length && start < b.length && a[start] === b[start]) {
|
|
369
|
-
out.push({ type:
|
|
436
|
+
out.push({ type: "same", text: a[start] });
|
|
370
437
|
start += 1;
|
|
371
438
|
}
|
|
372
439
|
var endA = a.length;
|
|
@@ -382,117 +449,111 @@
|
|
|
382
449
|
var i;
|
|
383
450
|
var j;
|
|
384
451
|
if (n * m > LCS_LIMIT) {
|
|
385
|
-
x.forEach(function
|
|
386
|
-
|
|
452
|
+
x.forEach(function(t) {
|
|
453
|
+
out.push({ type: "del", text: t });
|
|
454
|
+
});
|
|
455
|
+
y.forEach(function(t) {
|
|
456
|
+
out.push({ type: "add", text: t });
|
|
457
|
+
});
|
|
387
458
|
} else {
|
|
388
459
|
var w = m + 1;
|
|
389
460
|
var dp = new Int32Array((n + 1) * w);
|
|
390
461
|
for (i = n - 1; i >= 0; i -= 1) {
|
|
391
462
|
for (j = m - 1; j >= 0; j -= 1) {
|
|
392
|
-
dp[i * w + j] = x[i] === y[j]
|
|
393
|
-
? dp[(i + 1) * w + j + 1] + 1
|
|
394
|
-
: Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
|
|
463
|
+
dp[i * w + j] = x[i] === y[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
|
|
395
464
|
}
|
|
396
465
|
}
|
|
397
466
|
i = 0;
|
|
398
467
|
j = 0;
|
|
399
468
|
while (i < n && j < m) {
|
|
400
469
|
if (x[i] === y[j]) {
|
|
401
|
-
out.push({ type:
|
|
470
|
+
out.push({ type: "same", text: x[i] });
|
|
402
471
|
i += 1;
|
|
403
472
|
j += 1;
|
|
404
473
|
} else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
|
|
405
|
-
out.push({ type:
|
|
474
|
+
out.push({ type: "del", text: x[i] });
|
|
406
475
|
i += 1;
|
|
407
476
|
} else {
|
|
408
|
-
out.push({ type:
|
|
477
|
+
out.push({ type: "add", text: y[j] });
|
|
409
478
|
j += 1;
|
|
410
479
|
}
|
|
411
480
|
}
|
|
412
|
-
for (; i < n; i += 1) {
|
|
413
|
-
|
|
481
|
+
for (; i < n; i += 1) {
|
|
482
|
+
out.push({ type: "del", text: x[i] });
|
|
483
|
+
}
|
|
484
|
+
for (; j < m; j += 1) {
|
|
485
|
+
out.push({ type: "add", text: y[j] });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (i = endA; i < a.length; i += 1) {
|
|
489
|
+
out.push({ type: "same", text: a[i] });
|
|
414
490
|
}
|
|
415
|
-
for (i = endA; i < a.length; i += 1) { out.push({ type: 'same', text: a[i] }); }
|
|
416
491
|
return out;
|
|
417
492
|
}
|
|
418
|
-
|
|
419
|
-
var
|
|
420
|
-
var DIFF_SR = { add: 'Added: ', del: 'Removed: ', same: '' };
|
|
421
|
-
|
|
493
|
+
var DIFF_SIGN = { add: "+", del: "−", same: " " };
|
|
494
|
+
var DIFF_SR = { add: "Added: ", del: "Removed: ", same: "" };
|
|
422
495
|
function diffLineHtml(line) {
|
|
423
|
-
return '<span class="mp-ai-diff-line is-' + line.type + '">'
|
|
424
|
-
+ '<span class="mp-ai-diff-sign" aria-hidden="true">' + DIFF_SIGN[line.type] + '</span>'
|
|
425
|
-
+ (DIFF_SR[line.type] ? '<span class="mp-ai-sr-only">' + DIFF_SR[line.type] + '</span>' : '')
|
|
426
|
-
+ escapeHtml(line.text) + '</span>';
|
|
496
|
+
return '<span class="mp-ai-diff-line is-' + line.type + '"><span class="mp-ai-diff-sign" aria-hidden="true">' + DIFF_SIGN[line.type] + "</span>" + (DIFF_SR[line.type] ? '<span class="mp-ai-sr-only">' + DIFF_SR[line.type] + "</span>" : "") + escapeHtml(line.text) + "</span>";
|
|
427
497
|
}
|
|
428
|
-
|
|
429
|
-
/** Replaces a previous listener of the same kind that MpKit.ai bound to el. */
|
|
430
498
|
function bind(el, type, fn) {
|
|
431
|
-
var
|
|
432
|
-
if (
|
|
433
|
-
|
|
499
|
+
var store2 = el.__mpAiListeners || (el.__mpAiListeners = {});
|
|
500
|
+
if (store2[type]) {
|
|
501
|
+
el.removeEventListener(type, store2[type]);
|
|
502
|
+
}
|
|
503
|
+
store2[type] = fn;
|
|
434
504
|
el.addEventListener(type, fn);
|
|
435
505
|
}
|
|
436
|
-
|
|
437
506
|
function actionTarget(ev) {
|
|
438
507
|
var t = ev && ev.target;
|
|
439
|
-
return t && typeof t.closest ===
|
|
508
|
+
return t && typeof t.closest === "function" ? t.closest("[data-mp-ai-action]") : null;
|
|
440
509
|
}
|
|
441
|
-
|
|
442
510
|
function badgeHtml(label) {
|
|
443
|
-
return '<span class="mp-ai-badge">' + SPARKLE + escapeHtml(label ||
|
|
511
|
+
return '<span class="mp-ai-badge">' + SPARKLE + escapeHtml(label || "Assistant") + "</span>";
|
|
444
512
|
}
|
|
445
|
-
|
|
446
513
|
function thinkingHtml(label) {
|
|
447
|
-
return '<span class="mp-ai-thinking" role="status">' + escapeHtml(label ||
|
|
514
|
+
return '<span class="mp-ai-thinking" role="status">' + escapeHtml(label || "Thinking…") + "</span>";
|
|
448
515
|
}
|
|
449
|
-
|
|
450
516
|
var edgeEl = null;
|
|
451
|
-
|
|
452
517
|
var ai = {
|
|
453
|
-
|
|
454
518
|
/** GET-style status of the Assistant: { enabled, provider, model, capabilities }. */
|
|
455
|
-
status: function
|
|
519
|
+
status: function() {
|
|
456
520
|
var hb = hbClient();
|
|
457
|
-
if (!hb || typeof hb.request !==
|
|
458
|
-
return Promise.reject(new Error(
|
|
521
|
+
if (!hb || typeof hb.request !== "function") {
|
|
522
|
+
return Promise.reject(new Error("MpKit.ai needs the Homebridge plugin UI (window.homebridge)"));
|
|
459
523
|
}
|
|
460
|
-
return Promise.resolve(hb.request(
|
|
524
|
+
return Promise.resolve(hb.request("/ai/status"));
|
|
525
|
+
},
|
|
526
|
+
/**
|
|
527
|
+
* Explains a device/plugin error. body: { error, context?, device? } → { text, usage }
|
|
528
|
+
* opts: { onChunk, onDone, onError, signal, notifyServer = true }; the
|
|
529
|
+
* returned promise has .cancel() and .requestId.
|
|
530
|
+
*/
|
|
531
|
+
explain: function(body, opts) {
|
|
532
|
+
return aiRequest("/ai/explain", body, opts);
|
|
461
533
|
},
|
|
462
|
-
|
|
463
|
-
/** Explains a device/plugin error. body: { error, context?, device? } → { text, usage } */
|
|
464
|
-
explain: function (body, opts) { return aiRequest('/ai/explain', body, opts); },
|
|
465
|
-
|
|
466
534
|
/** Free-form question. body: { prompt, context? } → { text, usage } */
|
|
467
|
-
ask: function
|
|
468
|
-
|
|
535
|
+
ask: function(body, opts) {
|
|
536
|
+
return aiRequest("/ai/ask", body, opts);
|
|
537
|
+
},
|
|
469
538
|
/** Generates plugin config. body: { schema, request, current? } → { config, explanation, usage } */
|
|
470
|
-
config: function
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
539
|
+
config: function(body, opts) {
|
|
540
|
+
return aiRequest("/ai/config", body, opts);
|
|
541
|
+
},
|
|
542
|
+
markdown,
|
|
543
|
+
diffLines,
|
|
475
544
|
/**
|
|
476
545
|
* Returns HTML for an Assistant button (sparkle icon + escaped label).
|
|
477
546
|
* opts: { label = 'Ask Assistant', id, className, size: 'sm' | 'lg', title }
|
|
478
547
|
*/
|
|
479
|
-
renderButton: function
|
|
548
|
+
renderButton: function(opts) {
|
|
480
549
|
opts = opts || {};
|
|
481
|
-
var cls =
|
|
482
|
-
|
|
483
|
-
+ (opts.className ? ' ' + escapeHtml(opts.className) : '');
|
|
484
|
-
return '<button type="button" class="' + cls + '"'
|
|
485
|
-
+ (opts.id ? ' id="' + escapeHtml(opts.id) + '"' : '')
|
|
486
|
-
+ (opts.title ? ' title="' + escapeHtml(opts.title) + '"' : '')
|
|
487
|
-
+ '>' + SPARKLE + '<span>' + escapeHtml(opts.label || 'Ask Assistant') + '</span></button>';
|
|
550
|
+
var cls = "mp-ai-button" + (opts.size === "sm" || opts.size === "lg" ? " mp-ai-button-" + opts.size : "") + (opts.className ? " " + escapeHtml(opts.className) : "");
|
|
551
|
+
return '<button type="button" class="' + cls + '"' + (opts.id ? ' id="' + escapeHtml(opts.id) + '"' : "") + (opts.title ? ' title="' + escapeHtml(opts.title) + '"' : "") + ">" + SPARKLE + "<span>" + escapeHtml(opts.label || "Ask Assistant") + "</span></button>";
|
|
488
552
|
},
|
|
489
|
-
|
|
490
553
|
/** Returns HTML for a small "Assistant" badge. */
|
|
491
554
|
renderBadge: badgeHtml,
|
|
492
|
-
|
|
493
555
|
/** Returns HTML for a pulsing "Thinking…" indicator (a status live region). */
|
|
494
556
|
renderThinking: thinkingHtml,
|
|
495
|
-
|
|
496
557
|
/**
|
|
497
558
|
* Renders an answer panel into el and returns a controller for streaming:
|
|
498
559
|
* const answer = MpKit.ai.renderAnswer(el);
|
|
@@ -501,62 +562,58 @@
|
|
|
501
562
|
* opts: { label = 'Assistant', title, text, streaming = true, note }
|
|
502
563
|
* The text is rendered as escaped, safe markdown.
|
|
503
564
|
*/
|
|
504
|
-
renderAnswer: function
|
|
565
|
+
renderAnswer: function(el, opts) {
|
|
505
566
|
opts = opts || {};
|
|
506
|
-
var note = opts.note ===
|
|
507
|
-
el.innerHTML = '<section class="mp-ai-panel" aria-label="' + escapeHtml(opts.label ||
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
+ '<p class="mp-ai-error" role="alert" hidden></p>'
|
|
513
|
-
+ (note ? '<p class="mp-ai-panel-note">' + escapeHtml(note) + '</p>' : '')
|
|
514
|
-
+ '</section>';
|
|
515
|
-
var panel = el.querySelector('.mp-ai-panel');
|
|
516
|
-
var body = el.querySelector('.mp-ai-panel-body');
|
|
517
|
-
var errorEl = el.querySelector('.mp-ai-error');
|
|
518
|
-
var text = opts.text == null ? '' : String(opts.text);
|
|
567
|
+
var note = opts.note === void 0 ? DEFAULT_NOTE : opts.note;
|
|
568
|
+
el.innerHTML = '<section class="mp-ai-panel" aria-label="' + escapeHtml(opts.label || "Assistant") + ' answer"><div class="mp-ai-panel-header">' + badgeHtml(opts.label) + (opts.title ? '<p class="mp-ai-panel-title">' + escapeHtml(opts.title) + "</p>" : "") + '</div><div class="mp-ai-panel-body mp-ai-prose"></div><p class="mp-ai-error" role="alert" hidden></p>' + (note ? '<p class="mp-ai-panel-note">' + escapeHtml(note) + "</p>" : "") + "</section>";
|
|
569
|
+
var panel = el.querySelector(".mp-ai-panel");
|
|
570
|
+
var body = el.querySelector(".mp-ai-panel-body");
|
|
571
|
+
var errorEl = el.querySelector(".mp-ai-error");
|
|
572
|
+
var text = opts.text == null ? "" : String(opts.text);
|
|
519
573
|
var streaming = opts.streaming !== false;
|
|
520
|
-
|
|
521
574
|
function paint() {
|
|
522
575
|
var html = markdown(text);
|
|
523
|
-
if (streaming) {
|
|
576
|
+
if (streaming) {
|
|
577
|
+
html = text ? withCaret(html) : thinkingHtml();
|
|
578
|
+
}
|
|
524
579
|
body.innerHTML = html;
|
|
525
|
-
panel.classList.toggle(
|
|
526
|
-
panel.setAttribute(
|
|
580
|
+
panel.classList.toggle("is-streaming", streaming);
|
|
581
|
+
panel.setAttribute("aria-busy", streaming ? "true" : "false");
|
|
527
582
|
}
|
|
528
|
-
|
|
529
583
|
var controller = {
|
|
530
|
-
append: function
|
|
531
|
-
text += delta == null ?
|
|
584
|
+
append: function(delta) {
|
|
585
|
+
text += delta == null ? "" : String(delta);
|
|
532
586
|
paint();
|
|
533
587
|
return controller;
|
|
534
588
|
},
|
|
535
|
-
setText: function
|
|
536
|
-
text = value == null ?
|
|
589
|
+
setText: function(value) {
|
|
590
|
+
text = value == null ? "" : String(value);
|
|
537
591
|
paint();
|
|
538
592
|
return controller;
|
|
539
593
|
},
|
|
540
|
-
done: function
|
|
541
|
-
var final = result && typeof result ===
|
|
542
|
-
if (typeof final ===
|
|
594
|
+
done: function(result) {
|
|
595
|
+
var final = result && typeof result === "object" ? result.text : result;
|
|
596
|
+
if (typeof final === "string" && final) {
|
|
597
|
+
text = final;
|
|
598
|
+
}
|
|
543
599
|
streaming = false;
|
|
544
600
|
paint();
|
|
545
601
|
return controller;
|
|
546
602
|
},
|
|
547
|
-
error: function
|
|
603
|
+
error: function(err) {
|
|
548
604
|
streaming = false;
|
|
549
605
|
paint();
|
|
550
606
|
errorEl.textContent = errorMessage(err, DEFAULT_ERROR);
|
|
551
607
|
errorEl.hidden = false;
|
|
552
608
|
return controller;
|
|
553
609
|
},
|
|
554
|
-
getText: function
|
|
610
|
+
getText: function() {
|
|
611
|
+
return text;
|
|
612
|
+
}
|
|
555
613
|
};
|
|
556
614
|
paint();
|
|
557
615
|
return controller;
|
|
558
616
|
},
|
|
559
|
-
|
|
560
617
|
/**
|
|
561
618
|
* Renders a line diff with Apply / Reject buttons into el.
|
|
562
619
|
* before/after may be strings or JSON-serialisable values (pretty-printed).
|
|
@@ -565,79 +622,60 @@
|
|
|
565
622
|
* opts: { before, after, onApply, onReject, title, applyLabel, rejectLabel }
|
|
566
623
|
* Returns { lines, added, removed }.
|
|
567
624
|
*/
|
|
568
|
-
renderDiff: function
|
|
625
|
+
renderDiff: function(el, opts) {
|
|
569
626
|
opts = opts || {};
|
|
570
627
|
var lines = diffLines(opts.before, opts.after);
|
|
571
|
-
var added = lines.filter(function
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
var
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
+ (hasActions
|
|
587
|
-
? '<div class="mp-ai-diff-actions">'
|
|
588
|
-
+ '<span class="mp-ai-diff-status" role="status"></span>'
|
|
589
|
-
+ '<button type="button" class="mp-ai-action" data-mp-ai-action="reject">'
|
|
590
|
-
+ escapeHtml(opts.rejectLabel || 'Reject') + '</button>'
|
|
591
|
-
+ '<button type="button" class="mp-ai-action mp-ai-action-primary" data-mp-ai-action="apply">'
|
|
592
|
-
+ escapeHtml(opts.applyLabel || 'Apply') + '</button>'
|
|
593
|
-
+ '</div>'
|
|
594
|
-
: '')
|
|
595
|
-
+ '</div>';
|
|
596
|
-
|
|
597
|
-
var result = { lines: lines, added: added, removed: removed };
|
|
598
|
-
if (!hasActions) { return result; }
|
|
599
|
-
|
|
600
|
-
var root = el.querySelector('.mp-ai-diff');
|
|
601
|
-
var statusEl = el.querySelector('.mp-ai-diff-status');
|
|
628
|
+
var added = lines.filter(function(l) {
|
|
629
|
+
return l.type === "add";
|
|
630
|
+
}).length;
|
|
631
|
+
var removed = lines.filter(function(l) {
|
|
632
|
+
return l.type === "del";
|
|
633
|
+
}).length;
|
|
634
|
+
var hasActions = typeof opts.onApply === "function" || typeof opts.onReject === "function";
|
|
635
|
+
var title = opts.title || "Suggested changes";
|
|
636
|
+
el.innerHTML = '<div class="mp-ai-diff"><div class="mp-ai-diff-header">' + badgeHtml(opts.label) + '<span class="mp-ai-diff-title">' + escapeHtml(title) + '</span><span class="mp-ai-diff-stats"><span class="mp-ai-diff-stat-add">+' + added + '<span class="mp-ai-sr-only"> added</span></span><span class="mp-ai-diff-stat-del">−' + removed + '<span class="mp-ai-sr-only"> removed</span></span></span></div><pre class="mp-ai-diff-body" tabindex="0" aria-label="' + escapeHtml(title) + '"><code>' + (added || removed ? lines.map(diffLineHtml).join("") : '<span class="mp-ai-diff-line">No changes</span>') + "</code></pre>" + (hasActions ? '<div class="mp-ai-diff-actions"><span class="mp-ai-diff-status" role="status"></span><button type="button" class="mp-ai-action" data-mp-ai-action="reject">' + escapeHtml(opts.rejectLabel || "Reject") + '</button><button type="button" class="mp-ai-action mp-ai-action-primary" data-mp-ai-action="apply">' + escapeHtml(opts.applyLabel || "Apply") + "</button></div>" : "") + "</div>";
|
|
637
|
+
var result = { lines, added, removed };
|
|
638
|
+
if (!hasActions) {
|
|
639
|
+
return result;
|
|
640
|
+
}
|
|
641
|
+
var root2 = el.querySelector(".mp-ai-diff");
|
|
642
|
+
var statusEl = el.querySelector(".mp-ai-diff-status");
|
|
602
643
|
var applyBtn = el.querySelector('[data-mp-ai-action="apply"]');
|
|
603
644
|
var rejectBtn = el.querySelector('[data-mp-ai-action="reject"]');
|
|
604
645
|
var settled = false;
|
|
605
646
|
var busy = false;
|
|
606
|
-
|
|
607
647
|
function setDisabled(disabled) {
|
|
608
648
|
applyBtn.disabled = disabled;
|
|
609
649
|
rejectBtn.disabled = disabled;
|
|
610
650
|
}
|
|
611
|
-
|
|
612
|
-
bind(el, 'click', function (ev) {
|
|
651
|
+
bind(el, "click", function(ev) {
|
|
613
652
|
var btn = actionTarget(ev);
|
|
614
|
-
if (!btn || settled || busy) {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
var
|
|
653
|
+
if (!btn || settled || busy) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
var action = btn.getAttribute("data-mp-ai-action");
|
|
657
|
+
if (action !== "apply" && action !== "reject") {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
var cb = action === "apply" ? opts.onApply : opts.onReject;
|
|
618
661
|
busy = true;
|
|
619
662
|
setDisabled(true);
|
|
620
|
-
statusEl.textContent =
|
|
621
|
-
Promise.resolve()
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
busy = false;
|
|
634
|
-
setDisabled(false);
|
|
635
|
-
statusEl.textContent = errorMessage(err, 'Something went wrong');
|
|
636
|
-
});
|
|
663
|
+
statusEl.textContent = "";
|
|
664
|
+
Promise.resolve().then(function() {
|
|
665
|
+
return typeof cb === "function" ? cb({ before: opts.before, after: opts.after, lines }) : void 0;
|
|
666
|
+
}).then(function() {
|
|
667
|
+
busy = false;
|
|
668
|
+
settled = true;
|
|
669
|
+
root2.classList.add(action === "apply" ? "is-applied" : "is-rejected");
|
|
670
|
+
statusEl.textContent = action === "apply" ? "Applied" : "Rejected";
|
|
671
|
+
}, function(err) {
|
|
672
|
+
busy = false;
|
|
673
|
+
setDisabled(false);
|
|
674
|
+
statusEl.textContent = errorMessage(err, "Something went wrong");
|
|
675
|
+
});
|
|
637
676
|
});
|
|
638
677
|
return result;
|
|
639
678
|
},
|
|
640
|
-
|
|
641
679
|
/**
|
|
642
680
|
* Renders a chat (log + input) into el. Each message is sent with
|
|
643
681
|
* MpKit.ai.ask({ prompt, context }) and streamed into the log; the context is
|
|
@@ -645,150 +683,1605 @@
|
|
|
645
683
|
* { onChunk, history }) to use another transport; it may resolve with a
|
|
646
684
|
* string or { text }.
|
|
647
685
|
* opts: { label = 'Assistant', context, placeholder, emptyText, onSend }
|
|
648
|
-
*
|
|
686
|
+
* onSend also receives ctx.signal (an AbortSignal) for chat.cancel().
|
|
687
|
+
* Returns { send(text?), cancel(), clear(), messages() }.
|
|
649
688
|
*/
|
|
650
|
-
renderChat: function
|
|
689
|
+
renderChat: function(el, opts) {
|
|
651
690
|
opts = opts || {};
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
+ escapeHtml(opts.label || 'Assistant') + ' conversation"></div>'
|
|
658
|
-
+ '<form class="mp-ai-chat-form">'
|
|
659
|
-
+ '<label class="mp-ai-sr-only" for="' + inputId + '">Message</label>'
|
|
660
|
-
+ '<textarea class="mp-ai-chat-input" id="' + inputId + '" rows="1" placeholder="'
|
|
661
|
-
+ escapeHtml(opts.placeholder || 'Ask the Assistant…') + '"></textarea>'
|
|
662
|
-
+ '<button type="submit" class="mp-ai-button">' + SPARKLE + '<span>Send</span></button>'
|
|
663
|
-
+ '</form></div>';
|
|
664
|
-
|
|
665
|
-
var log = el.querySelector('.mp-ai-chat-log');
|
|
666
|
-
var input = el.querySelector('.mp-ai-chat-input');
|
|
667
|
-
var sendBtn = el.querySelector('.mp-ai-chat-form .mp-ai-button');
|
|
691
|
+
var inputId = uid("mp-ai-chat-input");
|
|
692
|
+
el.innerHTML = '<div class="mp-ai-chat"><div class="mp-ai-chat-header">' + badgeHtml(opts.label) + '</div><div class="mp-ai-chat-log" role="log" aria-live="polite" aria-label="' + escapeHtml(opts.label || "Assistant") + ' conversation"></div><form class="mp-ai-chat-form"><label class="mp-ai-sr-only" for="' + inputId + '">Message</label><textarea class="mp-ai-chat-input" id="' + inputId + '" rows="1" placeholder="' + escapeHtml(opts.placeholder || "Ask the Assistant…") + '"></textarea><button type="submit" class="mp-ai-button">' + SPARKLE + "<span>Send</span></button></form></div>";
|
|
693
|
+
var log = el.querySelector(".mp-ai-chat-log");
|
|
694
|
+
var input = el.querySelector(".mp-ai-chat-input");
|
|
695
|
+
var sendBtn = el.querySelector(".mp-ai-chat-form .mp-ai-button");
|
|
668
696
|
var messages = [];
|
|
669
697
|
var busy = false;
|
|
670
|
-
|
|
671
698
|
function messageHtml(m) {
|
|
672
|
-
if (m.role ===
|
|
673
|
-
return '<div class="mp-ai-chat-msg is-user"><div class="mp-ai-chat-bubble">'
|
|
674
|
-
+ '<span class="mp-ai-sr-only">You: </span>'
|
|
675
|
-
+ escapeHtml(m.text).replace(/\n/g, '<br>') + '</div></div>';
|
|
699
|
+
if (m.role === "user") {
|
|
700
|
+
return '<div class="mp-ai-chat-msg is-user"><div class="mp-ai-chat-bubble"><span class="mp-ai-sr-only">You: </span>' + escapeHtml(m.text).replace(/\n/g, "<br>") + "</div></div>";
|
|
676
701
|
}
|
|
677
702
|
var html = markdown(m.text);
|
|
678
|
-
if (m.streaming) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
+
|
|
703
|
+
if (m.streaming) {
|
|
704
|
+
html = m.text ? withCaret(html) : thinkingHtml();
|
|
705
|
+
}
|
|
706
|
+
if (m.error) {
|
|
707
|
+
html += '<p class="mp-ai-error" role="alert">' + escapeHtml(m.error) + "</p>";
|
|
708
|
+
}
|
|
709
|
+
if (m.stopped) {
|
|
710
|
+
html += '<p class="mp-ai-panel-note">Stopped</p>';
|
|
711
|
+
}
|
|
712
|
+
return '<div class="mp-ai-chat-msg is-assistant"><div class="mp-ai-chat-bubble mp-ai-prose"><span class="mp-ai-sr-only">' + escapeHtml(opts.label || "Assistant") + ": </span>" + html + "</div></div>";
|
|
683
713
|
}
|
|
684
|
-
|
|
685
714
|
function paint() {
|
|
686
|
-
log.innerHTML = messages.length
|
|
687
|
-
|
|
688
|
-
: '<p class="mp-ai-chat-empty">' + escapeHtml(opts.emptyText || 'Ask the Assistant a question.') + '</p>';
|
|
689
|
-
log.setAttribute('aria-busy', busy ? 'true' : 'false');
|
|
715
|
+
log.innerHTML = messages.length ? messages.map(messageHtml).join("") : '<p class="mp-ai-chat-empty">' + escapeHtml(opts.emptyText || "Ask the Assistant a question.") + "</p>";
|
|
716
|
+
log.setAttribute("aria-busy", busy ? "true" : "false");
|
|
690
717
|
log.scrollTop = log.scrollHeight;
|
|
691
718
|
sendBtn.disabled = busy;
|
|
692
|
-
sendBtn.setAttribute(
|
|
719
|
+
sendBtn.setAttribute("aria-busy", busy ? "true" : "false");
|
|
693
720
|
}
|
|
694
|
-
|
|
695
721
|
function defaultSend(prompt, ctx) {
|
|
696
|
-
var transcript = ctx.history.slice(-10).map(function
|
|
697
|
-
return (m.role ===
|
|
698
|
-
}).join(
|
|
699
|
-
var context = [opts.context, transcript ?
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
722
|
+
var transcript = ctx.history.slice(-10).map(function(m) {
|
|
723
|
+
return (m.role === "user" ? "User" : "Assistant") + ": " + m.text;
|
|
724
|
+
}).join("\n");
|
|
725
|
+
var context = [opts.context, transcript ? "Conversation so far:\n" + transcript : ""].filter(Boolean).join("\n\n");
|
|
726
|
+
var body = { prompt };
|
|
727
|
+
if (context) {
|
|
728
|
+
body.context = context;
|
|
729
|
+
}
|
|
730
|
+
return ai.ask(body, { onChunk: ctx.onChunk, signal: ctx.signal });
|
|
704
731
|
}
|
|
705
|
-
|
|
732
|
+
var controller = null;
|
|
706
733
|
function send(text) {
|
|
707
734
|
var prompt = String(text == null ? input.value : text).trim();
|
|
708
|
-
if (!prompt || busy) {
|
|
709
|
-
|
|
710
|
-
|
|
735
|
+
if (!prompt || busy) {
|
|
736
|
+
return Promise.resolve(null);
|
|
737
|
+
}
|
|
738
|
+
if (text == null) {
|
|
739
|
+
input.value = "";
|
|
740
|
+
}
|
|
741
|
+
var history = messages.filter(function(m) {
|
|
742
|
+
return !m.error && !m.stopped;
|
|
743
|
+
}).map(function(m) {
|
|
711
744
|
return { role: m.role, text: m.text };
|
|
712
745
|
});
|
|
713
|
-
var reply = { role:
|
|
714
|
-
messages.push({ role:
|
|
746
|
+
var reply = { role: "assistant", text: "", streaming: true };
|
|
747
|
+
messages.push({ role: "user", text: prompt }, reply);
|
|
715
748
|
busy = true;
|
|
716
749
|
paint();
|
|
717
|
-
var handler = typeof opts.onSend ===
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
.then(function (res) {
|
|
729
|
-
var final = res && typeof res === 'object' ? res.text : res;
|
|
730
|
-
if (typeof final === 'string' && final) { reply.text = final; }
|
|
731
|
-
}, function (err) {
|
|
732
|
-
reply.error = errorMessage(err, DEFAULT_ERROR);
|
|
733
|
-
})
|
|
734
|
-
.then(function () {
|
|
735
|
-
reply.streaming = false;
|
|
736
|
-
busy = false;
|
|
737
|
-
paint();
|
|
738
|
-
return reply.error ? null : reply.text;
|
|
750
|
+
var handler = typeof opts.onSend === "function" ? opts.onSend : defaultSend;
|
|
751
|
+
controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
752
|
+
var signal = controller ? controller.signal : void 0;
|
|
753
|
+
return Promise.resolve().then(function() {
|
|
754
|
+
return handler(prompt, {
|
|
755
|
+
history,
|
|
756
|
+
signal,
|
|
757
|
+
onChunk: function(delta) {
|
|
758
|
+
reply.text += delta == null ? "" : String(delta);
|
|
759
|
+
paint();
|
|
760
|
+
}
|
|
739
761
|
});
|
|
762
|
+
}).then(function(res) {
|
|
763
|
+
var final = res && typeof res === "object" ? res.text : res;
|
|
764
|
+
if (typeof final === "string" && final) {
|
|
765
|
+
reply.text = final;
|
|
766
|
+
}
|
|
767
|
+
}, function(err) {
|
|
768
|
+
if (err && err.name === "AbortError" || signal && signal.aborted) {
|
|
769
|
+
reply.stopped = true;
|
|
770
|
+
} else {
|
|
771
|
+
reply.error = errorMessage(err, DEFAULT_ERROR);
|
|
772
|
+
}
|
|
773
|
+
}).then(function() {
|
|
774
|
+
reply.streaming = false;
|
|
775
|
+
busy = false;
|
|
776
|
+
controller = null;
|
|
777
|
+
paint();
|
|
778
|
+
return reply.error || reply.stopped ? null : reply.text;
|
|
779
|
+
});
|
|
740
780
|
}
|
|
741
|
-
|
|
742
|
-
bind(el, 'submit', function (ev) {
|
|
781
|
+
bind(el, "submit", function(ev) {
|
|
743
782
|
ev.preventDefault();
|
|
744
783
|
send();
|
|
745
784
|
});
|
|
746
|
-
bind(el,
|
|
785
|
+
bind(el, "keydown", function(ev) {
|
|
747
786
|
var t = ev.target;
|
|
748
|
-
if (ev.key ===
|
|
787
|
+
if (ev.key === "Enter" && !ev.shiftKey && !ev.isComposing && t === input) {
|
|
749
788
|
ev.preventDefault();
|
|
750
789
|
send();
|
|
751
790
|
}
|
|
752
791
|
});
|
|
753
792
|
paint();
|
|
754
|
-
|
|
755
793
|
return {
|
|
756
|
-
send
|
|
757
|
-
|
|
794
|
+
send,
|
|
795
|
+
/** Stops the reply in progress (aborts the signal passed to onSend / ask). */
|
|
796
|
+
cancel: function() {
|
|
797
|
+
if (!controller) {
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
controller.abort();
|
|
801
|
+
return true;
|
|
802
|
+
},
|
|
803
|
+
clear: function() {
|
|
758
804
|
messages = [];
|
|
759
805
|
paint();
|
|
760
806
|
},
|
|
761
|
-
messages: function
|
|
762
|
-
return messages.map(function
|
|
807
|
+
messages: function() {
|
|
808
|
+
return messages.map(function(m) {
|
|
763
809
|
return { role: m.role, text: m.text, error: m.error };
|
|
764
810
|
});
|
|
765
|
-
}
|
|
811
|
+
}
|
|
766
812
|
};
|
|
767
813
|
},
|
|
768
|
-
|
|
769
814
|
/**
|
|
770
815
|
* Shows (default) or hides a fixed glow around the page edges, e.g. while
|
|
771
816
|
* the Assistant works. Returns the element.
|
|
772
817
|
*/
|
|
773
|
-
edgeGlow: function
|
|
774
|
-
if (
|
|
775
|
-
if (edgeEl) {
|
|
818
|
+
edgeGlow: function(show2) {
|
|
819
|
+
if (show2 === false) {
|
|
820
|
+
if (edgeEl) {
|
|
821
|
+
edgeEl.hidden = true;
|
|
822
|
+
}
|
|
776
823
|
return edgeEl;
|
|
777
824
|
}
|
|
778
825
|
if (!edgeEl) {
|
|
779
|
-
edgeEl =
|
|
780
|
-
edgeEl.className =
|
|
781
|
-
edgeEl.setAttribute(
|
|
782
|
-
|
|
826
|
+
edgeEl = doc().createElement("div");
|
|
827
|
+
edgeEl.className = "mp-ai-edge-glow";
|
|
828
|
+
edgeEl.setAttribute("aria-hidden", "true");
|
|
829
|
+
doc().body.appendChild(edgeEl);
|
|
783
830
|
}
|
|
784
831
|
edgeEl.hidden = false;
|
|
785
832
|
return edgeEl;
|
|
786
|
-
}
|
|
787
|
-
|
|
833
|
+
}
|
|
788
834
|
};
|
|
789
835
|
|
|
790
|
-
|
|
836
|
+
// src/js/theme.js
|
|
837
|
+
var THEME_STORAGE_KEY = "mp-kit-theme";
|
|
838
|
+
var QUERY = "(prefers-color-scheme: dark)";
|
|
839
|
+
function normalizeTheme(value) {
|
|
840
|
+
if (typeof value !== "string") {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
const v = value.trim().toLowerCase();
|
|
844
|
+
if (v === "dark" || v === "dark-mode") {
|
|
845
|
+
return "dark";
|
|
846
|
+
}
|
|
847
|
+
if (v === "light" || v === "light-mode") {
|
|
848
|
+
return "light";
|
|
849
|
+
}
|
|
850
|
+
if (v === "auto" || v === "system") {
|
|
851
|
+
return "auto";
|
|
852
|
+
}
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
function resolveTheme(preference, systemDark) {
|
|
856
|
+
return preference === "dark" || preference !== "light" && !!systemDark ? "dark" : "light";
|
|
857
|
+
}
|
|
858
|
+
function themeFromSettings(settings) {
|
|
859
|
+
if (!settings || typeof settings !== "object") {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
return normalizeTheme(settings.colorScheme) || normalizeTheme(settings.theme) || normalizeTheme(settings.lightingMode);
|
|
863
|
+
}
|
|
864
|
+
function mediaQuery() {
|
|
865
|
+
try {
|
|
866
|
+
return typeof root.matchMedia === "function" ? root.matchMedia(QUERY) : null;
|
|
867
|
+
} catch {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function store(key, value) {
|
|
872
|
+
if (!key) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
root.localStorage.setItem(key, value);
|
|
877
|
+
} catch {
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function stored(key) {
|
|
881
|
+
if (!key) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
return normalizeTheme(root.localStorage.getItem(key));
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async function homebridgeTheme() {
|
|
891
|
+
const hb = hbClient();
|
|
892
|
+
if (!hb) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
if (typeof hb.getUserSettings === "function") {
|
|
896
|
+
try {
|
|
897
|
+
const pref = themeFromSettings(await hb.getUserSettings());
|
|
898
|
+
if (pref) {
|
|
899
|
+
return pref;
|
|
900
|
+
}
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (typeof hb.userCurrentLightingMode === "function") {
|
|
905
|
+
try {
|
|
906
|
+
const pref = normalizeTheme(await hb.userCurrentLightingMode());
|
|
907
|
+
if (pref) {
|
|
908
|
+
return pref;
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
function applyTheme(resolved, opts) {
|
|
916
|
+
opts = opts || {};
|
|
917
|
+
const d = doc();
|
|
918
|
+
const target = opts.target || d && d.documentElement;
|
|
919
|
+
if (!target) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const dark = resolved === "dark";
|
|
923
|
+
if (opts.attribute !== false) {
|
|
924
|
+
target.setAttribute("data-bs-theme", dark ? "dark" : "light");
|
|
925
|
+
}
|
|
926
|
+
if (opts.className !== false) {
|
|
927
|
+
target.classList.toggle("mp-theme-dark", dark);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
var Theme = {
|
|
931
|
+
/**
|
|
932
|
+
* Applies the theme now (stored/system preference), then the Homebridge
|
|
933
|
+
* user's setting once it arrives, and follows system changes while the
|
|
934
|
+
* preference is 'auto'.
|
|
935
|
+
* opts: { target = <html>, theme ('light'|'dark'|'auto': skip Homebridge),
|
|
936
|
+
* homebridge = true, storageKey = 'mp-kit-theme' (false: none),
|
|
937
|
+
* attribute = true, className = true, onChange(resolved, preference) }
|
|
938
|
+
*/
|
|
939
|
+
init(opts) {
|
|
940
|
+
opts = opts || {};
|
|
941
|
+
const key = opts.storageKey === void 0 ? THEME_STORAGE_KEY : opts.storageKey;
|
|
942
|
+
const explicit = normalizeTheme(opts.theme);
|
|
943
|
+
let preference = explicit || stored(key) || "auto";
|
|
944
|
+
let current = null;
|
|
945
|
+
let pinned = !!explicit;
|
|
946
|
+
let destroyed = false;
|
|
947
|
+
const media = mediaQuery();
|
|
948
|
+
function update() {
|
|
949
|
+
const resolved = resolveTheme(preference, media && media.matches);
|
|
950
|
+
applyTheme(resolved, opts);
|
|
951
|
+
store(key, preference);
|
|
952
|
+
if (resolved !== current) {
|
|
953
|
+
const first = current === null;
|
|
954
|
+
current = resolved;
|
|
955
|
+
if (!first && typeof opts.onChange === "function") {
|
|
956
|
+
opts.onChange(resolved, preference);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function onMedia() {
|
|
961
|
+
if (!destroyed && preference === "auto") {
|
|
962
|
+
update();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (media) {
|
|
966
|
+
if (typeof media.addEventListener === "function") {
|
|
967
|
+
media.addEventListener("change", onMedia);
|
|
968
|
+
} else if (typeof media.addListener === "function") {
|
|
969
|
+
media.addListener(onMedia);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
update();
|
|
973
|
+
const controller = {
|
|
974
|
+
/** Resolves with the resolved theme once the Homebridge setting was applied. */
|
|
975
|
+
ready: null,
|
|
976
|
+
preference: () => preference,
|
|
977
|
+
resolved: () => current,
|
|
978
|
+
/** Overrides the preference ('light' | 'dark' | 'auto'). */
|
|
979
|
+
set(mode) {
|
|
980
|
+
preference = normalizeTheme(mode) || "auto";
|
|
981
|
+
pinned = true;
|
|
982
|
+
update();
|
|
983
|
+
return current;
|
|
984
|
+
},
|
|
985
|
+
destroy() {
|
|
986
|
+
destroyed = true;
|
|
987
|
+
if (!media) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (typeof media.removeEventListener === "function") {
|
|
991
|
+
media.removeEventListener("change", onMedia);
|
|
992
|
+
} else if (typeof media.removeListener === "function") {
|
|
993
|
+
media.removeListener(onMedia);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
controller.ready = (pinned || opts.homebridge === false ? Promise.resolve(null) : homebridgeTheme()).then((pref) => {
|
|
998
|
+
if (pref && !pinned && !destroyed) {
|
|
999
|
+
preference = pref;
|
|
1000
|
+
update();
|
|
1001
|
+
}
|
|
1002
|
+
return current;
|
|
1003
|
+
});
|
|
1004
|
+
return controller;
|
|
1005
|
+
},
|
|
1006
|
+
apply: applyTheme,
|
|
1007
|
+
resolve: resolveTheme,
|
|
1008
|
+
normalize: normalizeTheme,
|
|
1009
|
+
fromSettings: themeFromSettings
|
|
1010
|
+
};
|
|
791
1011
|
|
|
792
|
-
|
|
1012
|
+
// src/js/tabs.js
|
|
1013
|
+
function panelId(tab) {
|
|
1014
|
+
const ref = tab.getAttribute("data-mp-view") || tab.getAttribute("aria-controls") || tab.getAttribute("data-bs-target") || tab.getAttribute("href") || "";
|
|
1015
|
+
const id = ref.charAt(0) === "#" ? ref.slice(1) : ref;
|
|
1016
|
+
return /^[A-Za-z][\w:.-]*$/.test(id) ? id : null;
|
|
1017
|
+
}
|
|
1018
|
+
function isRtl(el) {
|
|
1019
|
+
const holder = el.closest("[dir]");
|
|
1020
|
+
if (holder) {
|
|
1021
|
+
return holder.getAttribute("dir").toLowerCase() === "rtl";
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
const view = el.ownerDocument.defaultView;
|
|
1025
|
+
return view.getComputedStyle(el).direction === "rtl";
|
|
1026
|
+
} catch {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
var Tabs = {
|
|
1031
|
+
/**
|
|
1032
|
+
* Enhances a tab bar. el: .mp-tabs element or selector.
|
|
1033
|
+
* opts: { activation: 'auto' | 'manual', onChange(tab, index, panel) }
|
|
1034
|
+
* Tabs reference their panel with data-mp-view (an .mp-view id, shown with
|
|
1035
|
+
* MpKit.View.show), aria-controls, data-bs-target or href="#id".
|
|
1036
|
+
* Returns { select(indexOrTab), selected(), tabs(), destroy() }.
|
|
1037
|
+
*/
|
|
1038
|
+
init(el, opts) {
|
|
1039
|
+
opts = opts || {};
|
|
1040
|
+
const root2 = typeof el === "string" ? doc().querySelector(el) : el;
|
|
1041
|
+
if (!root2) {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
const tabs = Array.from(root2.querySelectorAll('.nav-link, [role="tab"]'));
|
|
1045
|
+
const vertical = root2.getAttribute("aria-orientation") === "vertical";
|
|
1046
|
+
root2.setAttribute("role", "tablist");
|
|
1047
|
+
tabs.forEach((tab) => {
|
|
1048
|
+
if (tab.parentElement !== root2 && tab.parentElement.tagName === "LI") {
|
|
1049
|
+
tab.parentElement.setAttribute("role", "presentation");
|
|
1050
|
+
}
|
|
1051
|
+
tab.setAttribute("role", "tab");
|
|
1052
|
+
if (!tab.id) {
|
|
1053
|
+
tab.id = uid("mp-tab");
|
|
1054
|
+
}
|
|
1055
|
+
const id = panelId(tab);
|
|
1056
|
+
const panel = id && root2.ownerDocument.getElementById(id);
|
|
1057
|
+
if (panel) {
|
|
1058
|
+
tab.setAttribute("aria-controls", id);
|
|
1059
|
+
panel.setAttribute("role", "tabpanel");
|
|
1060
|
+
if (!panel.hasAttribute("aria-labelledby")) {
|
|
1061
|
+
panel.setAttribute("aria-labelledby", tab.id);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
let current = -1;
|
|
1066
|
+
function panelOf(tab) {
|
|
1067
|
+
const id = tab.getAttribute("aria-controls");
|
|
1068
|
+
return id ? root2.ownerDocument.getElementById(id) : null;
|
|
1069
|
+
}
|
|
1070
|
+
function select(target, fromUser) {
|
|
1071
|
+
const index = typeof target === "number" ? target : tabs.indexOf(target);
|
|
1072
|
+
if (index < 0 || index >= tabs.length) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const changed = index !== current;
|
|
1076
|
+
tabs.forEach((tab, i) => {
|
|
1077
|
+
const on = i === index;
|
|
1078
|
+
tab.setAttribute("aria-selected", on ? "true" : "false");
|
|
1079
|
+
tab.setAttribute("tabindex", on ? "0" : "-1");
|
|
1080
|
+
tab.classList.toggle("active", on);
|
|
1081
|
+
const panel2 = panelOf(tab);
|
|
1082
|
+
if (!panel2 || panel2.classList.contains("mp-view")) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (panel2.classList.contains("tab-pane")) {
|
|
1086
|
+
panel2.classList.toggle("active", on);
|
|
1087
|
+
panel2.classList.toggle("show", on);
|
|
1088
|
+
} else {
|
|
1089
|
+
panel2.hidden = !on;
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
const panel = panelOf(tabs[index]);
|
|
1093
|
+
if (panel && panel.classList.contains("mp-view")) {
|
|
1094
|
+
View.show(panel.id, { focus: false, announce: false });
|
|
1095
|
+
}
|
|
1096
|
+
current = index;
|
|
1097
|
+
if (changed && fromUser && typeof opts.onChange === "function") {
|
|
1098
|
+
opts.onChange(tabs[index], index, panel);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function onClick2(ev) {
|
|
1102
|
+
const tab = ev.target.closest('[role="tab"]');
|
|
1103
|
+
if (!tab || !root2.contains(tab)) {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (tab.tagName === "A") {
|
|
1107
|
+
ev.preventDefault();
|
|
1108
|
+
}
|
|
1109
|
+
select(tab, true);
|
|
1110
|
+
}
|
|
1111
|
+
function onKeydown2(ev) {
|
|
1112
|
+
const tab = ev.target.closest('[role="tab"]');
|
|
1113
|
+
if (!tab || !root2.contains(tab)) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const enabled = tabs.filter((t) => !t.disabled && t.getAttribute("aria-disabled") !== "true");
|
|
1117
|
+
const pos = enabled.indexOf(tab);
|
|
1118
|
+
const rtl = !vertical && isRtl(root2);
|
|
1119
|
+
const next = vertical ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
|
|
1120
|
+
const prev = vertical ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
|
|
1121
|
+
let to = null;
|
|
1122
|
+
if (ev.key === next) {
|
|
1123
|
+
to = enabled[(pos + 1) % enabled.length];
|
|
1124
|
+
} else if (ev.key === prev) {
|
|
1125
|
+
to = enabled[(pos - 1 + enabled.length) % enabled.length];
|
|
1126
|
+
} else if (ev.key === "Home") {
|
|
1127
|
+
to = enabled[0];
|
|
1128
|
+
} else if (ev.key === "End") {
|
|
1129
|
+
to = enabled[enabled.length - 1];
|
|
1130
|
+
} else if ((ev.key === "Enter" || ev.key === " ") && opts.activation === "manual") {
|
|
1131
|
+
ev.preventDefault();
|
|
1132
|
+
select(tab, true);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (!to) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
ev.preventDefault();
|
|
1139
|
+
if (opts.activation === "manual") {
|
|
1140
|
+
tabs.forEach((t) => t.setAttribute("tabindex", t === to ? "0" : "-1"));
|
|
1141
|
+
} else {
|
|
1142
|
+
select(to, true);
|
|
1143
|
+
}
|
|
1144
|
+
to.focus();
|
|
1145
|
+
}
|
|
1146
|
+
root2.addEventListener("click", onClick2);
|
|
1147
|
+
root2.addEventListener("keydown", onKeydown2);
|
|
1148
|
+
const initial = tabs.findIndex((t) => t.classList.contains("active") || t.getAttribute("aria-selected") === "true");
|
|
1149
|
+
select(initial < 0 ? 0 : initial, false);
|
|
1150
|
+
return {
|
|
1151
|
+
select: (target) => select(target, true),
|
|
1152
|
+
selected: () => current,
|
|
1153
|
+
tabs: () => tabs.slice(),
|
|
1154
|
+
destroy() {
|
|
1155
|
+
root2.removeEventListener("click", onClick2);
|
|
1156
|
+
root2.removeEventListener("keydown", onKeydown2);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// src/js/toast.js
|
|
1163
|
+
var TYPES = ["success", "error", "warning", "info"];
|
|
1164
|
+
var DURATION = { success: 5e3, info: 5e3, warning: 7e3, error: 8e3 };
|
|
1165
|
+
var ICON = { success: "✓", info: "i", warning: "!", error: "✕" };
|
|
1166
|
+
var container = null;
|
|
1167
|
+
function ensureContainer(d) {
|
|
1168
|
+
if (!container || !container.isConnected) {
|
|
1169
|
+
container = d.createElement("div");
|
|
1170
|
+
container.className = "mp-toast-container";
|
|
1171
|
+
d.body.appendChild(container);
|
|
1172
|
+
}
|
|
1173
|
+
return container;
|
|
1174
|
+
}
|
|
1175
|
+
function show(type, message, opts) {
|
|
1176
|
+
if (TYPES.indexOf(type) < 0) {
|
|
1177
|
+
type = "info";
|
|
1178
|
+
}
|
|
1179
|
+
opts = typeof opts === "string" ? { title: opts } : opts || {};
|
|
1180
|
+
const text = String(message == null ? "" : message);
|
|
1181
|
+
const hb = hbClient();
|
|
1182
|
+
if (!opts.local && hb && hb.toast && typeof hb.toast[type] === "function") {
|
|
1183
|
+
hb.toast[type](text, opts.title);
|
|
1184
|
+
return { close() {
|
|
1185
|
+
}, element: null, native: true };
|
|
1186
|
+
}
|
|
1187
|
+
const d = doc();
|
|
1188
|
+
if (!d || !d.body) {
|
|
1189
|
+
return { close() {
|
|
1190
|
+
}, element: null, native: false };
|
|
1191
|
+
}
|
|
1192
|
+
const el = d.createElement("div");
|
|
1193
|
+
el.className = "mp-toast mp-toast-" + type;
|
|
1194
|
+
el.setAttribute("role", type === "error" || type === "warning" ? "alert" : "status");
|
|
1195
|
+
el.setAttribute("aria-atomic", "true");
|
|
1196
|
+
el.innerHTML = '<span class="mp-toast-icon" aria-hidden="true">' + ICON[type] + '</span><div class="mp-toast-body">' + (opts.title ? '<p class="mp-toast-title">' + escapeHtml(opts.title) + "</p>" : "") + '<p class="mp-toast-message">' + escapeHtml(text) + '</p></div><button type="button" class="mp-toast-close" aria-label="Close">×</button>';
|
|
1197
|
+
ensureContainer(d).appendChild(el);
|
|
1198
|
+
let timer = null;
|
|
1199
|
+
let closed = false;
|
|
1200
|
+
const duration = opts.duration === void 0 ? DURATION[type] : Number(opts.duration) || 0;
|
|
1201
|
+
function close() {
|
|
1202
|
+
if (closed) {
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
closed = true;
|
|
1206
|
+
clearTimeout(timer);
|
|
1207
|
+
el.remove();
|
|
1208
|
+
}
|
|
1209
|
+
function arm() {
|
|
1210
|
+
clearTimeout(timer);
|
|
1211
|
+
if (duration > 0) {
|
|
1212
|
+
timer = setTimeout(close, duration);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
el.addEventListener("mouseenter", () => clearTimeout(timer));
|
|
1216
|
+
el.addEventListener("mouseleave", arm);
|
|
1217
|
+
el.addEventListener("focusin", () => clearTimeout(timer));
|
|
1218
|
+
el.addEventListener("focusout", arm);
|
|
1219
|
+
el.querySelector(".mp-toast-close").addEventListener("click", close);
|
|
1220
|
+
arm();
|
|
1221
|
+
return { close, element: el, native: false };
|
|
1222
|
+
}
|
|
1223
|
+
var Toast = {
|
|
1224
|
+
show,
|
|
1225
|
+
success: (message, opts) => show("success", message, opts),
|
|
1226
|
+
error: (message, opts) => show("error", message, opts),
|
|
1227
|
+
warning: (message, opts) => show("warning", message, opts),
|
|
1228
|
+
info: (message, opts) => show("info", message, opts)
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
// src/js/dialog.js
|
|
1232
|
+
function confirm(opts) {
|
|
1233
|
+
opts = typeof opts === "string" ? { message: opts } : opts || {};
|
|
1234
|
+
const d = doc();
|
|
1235
|
+
if (!d || !d.body) {
|
|
1236
|
+
return Promise.resolve(false);
|
|
1237
|
+
}
|
|
1238
|
+
const titleId = uid("mp-dialog-title");
|
|
1239
|
+
const messageId = uid("mp-dialog-message");
|
|
1240
|
+
const danger = !!opts.danger;
|
|
1241
|
+
const backdrop = d.createElement("div");
|
|
1242
|
+
backdrop.className = "mp-dialog-backdrop";
|
|
1243
|
+
backdrop.innerHTML = '<div class="mp-dialog' + (danger ? " mp-dialog-danger" : "") + '" role="alertdialog" aria-modal="true" aria-labelledby="' + titleId + '"' + (opts.message ? ' aria-describedby="' + messageId + '"' : "") + '><h2 class="mp-dialog-title" id="' + titleId + '">' + escapeHtml(opts.title || "Are you sure?") + "</h2>" + (opts.message ? '<p class="mp-dialog-message" id="' + messageId + '">' + escapeHtml(opts.message) + "</p>" : "") + '<div class="mp-dialog-actions"><button type="button" class="btn btn-outline-secondary" data-mp-dialog="cancel">' + escapeHtml(opts.cancelLabel || "Cancel") + '</button><button type="button" class="btn ' + (danger ? "btn-danger" : "btn-primary") + '" data-mp-dialog="confirm">' + escapeHtml(opts.confirmLabel || "Confirm") + "</button></div></div>";
|
|
1244
|
+
const dialog = backdrop.firstChild;
|
|
1245
|
+
const previous = d.activeElement;
|
|
1246
|
+
const inerted = Array.from(d.body.children).filter((n) => !n.hasAttribute("inert") && !n.hasAttribute("data-mp-live") && !n.classList.contains("mp-toast-container"));
|
|
1247
|
+
inerted.forEach((n) => n.setAttribute("inert", ""));
|
|
1248
|
+
d.body.appendChild(backdrop);
|
|
1249
|
+
d.body.classList.add("mp-dialog-open");
|
|
1250
|
+
return new Promise((resolve) => {
|
|
1251
|
+
function finish(result) {
|
|
1252
|
+
d.removeEventListener("keydown", onKey, true);
|
|
1253
|
+
backdrop.remove();
|
|
1254
|
+
inerted.forEach((n) => n.removeAttribute("inert"));
|
|
1255
|
+
if (!d.querySelector(".mp-dialog-backdrop")) {
|
|
1256
|
+
d.body.classList.remove("mp-dialog-open");
|
|
1257
|
+
}
|
|
1258
|
+
if (previous && typeof previous.focus === "function" && previous.isConnected) {
|
|
1259
|
+
try {
|
|
1260
|
+
previous.focus({ preventScroll: true });
|
|
1261
|
+
} catch {
|
|
1262
|
+
previous.focus();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
resolve(result);
|
|
1266
|
+
}
|
|
1267
|
+
function onKey(ev) {
|
|
1268
|
+
if (ev.key === "Escape") {
|
|
1269
|
+
ev.preventDefault();
|
|
1270
|
+
ev.stopPropagation();
|
|
1271
|
+
finish(false);
|
|
1272
|
+
} else if (ev.key === "Tab") {
|
|
1273
|
+
const items = focusables(dialog);
|
|
1274
|
+
if (!items.length) {
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
const first = items[0];
|
|
1278
|
+
const last = items[items.length - 1];
|
|
1279
|
+
const active = d.activeElement;
|
|
1280
|
+
if (ev.shiftKey && (active === first || !dialog.contains(active))) {
|
|
1281
|
+
ev.preventDefault();
|
|
1282
|
+
last.focus();
|
|
1283
|
+
} else if (!ev.shiftKey && (active === last || !dialog.contains(active))) {
|
|
1284
|
+
ev.preventDefault();
|
|
1285
|
+
first.focus();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
backdrop.addEventListener("click", (ev) => {
|
|
1290
|
+
const action = ev.target.closest && ev.target.closest("[data-mp-dialog]");
|
|
1291
|
+
if (action) {
|
|
1292
|
+
finish(action.getAttribute("data-mp-dialog") === "confirm");
|
|
1293
|
+
} else if (ev.target === backdrop) {
|
|
1294
|
+
finish(false);
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
d.addEventListener("keydown", onKey, true);
|
|
1298
|
+
dialog.querySelector('[data-mp-dialog="' + (danger ? "cancel" : "confirm") + '"]').focus();
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/js/form.js
|
|
1303
|
+
function copyText(text) {
|
|
1304
|
+
const value = String(text == null ? "" : text);
|
|
1305
|
+
const nav = root.navigator;
|
|
1306
|
+
if (nav && nav.clipboard && typeof nav.clipboard.writeText === "function") {
|
|
1307
|
+
return nav.clipboard.writeText(value).then(() => true, () => legacyCopy(value));
|
|
1308
|
+
}
|
|
1309
|
+
return Promise.resolve(legacyCopy(value));
|
|
1310
|
+
}
|
|
1311
|
+
function legacyCopy(value) {
|
|
1312
|
+
const d = doc();
|
|
1313
|
+
if (!d || !d.body || typeof d.execCommand !== "function") {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
const area = d.createElement("textarea");
|
|
1317
|
+
area.value = value;
|
|
1318
|
+
area.setAttribute("readonly", "");
|
|
1319
|
+
area.className = "mp-sr-only";
|
|
1320
|
+
d.body.appendChild(area);
|
|
1321
|
+
area.select();
|
|
1322
|
+
let ok = false;
|
|
1323
|
+
try {
|
|
1324
|
+
ok = d.execCommand("copy");
|
|
1325
|
+
} catch {
|
|
1326
|
+
ok = false;
|
|
1327
|
+
}
|
|
1328
|
+
area.remove();
|
|
1329
|
+
return !!ok;
|
|
1330
|
+
}
|
|
1331
|
+
function attrs(map) {
|
|
1332
|
+
return Object.keys(map).map((k) => {
|
|
1333
|
+
const v = map[k];
|
|
1334
|
+
if (v === void 0 || v === null || v === false) {
|
|
1335
|
+
return "";
|
|
1336
|
+
}
|
|
1337
|
+
return v === true ? " " + k : " " + k + '="' + escapeHtml(v) + '"';
|
|
1338
|
+
}).join("");
|
|
1339
|
+
}
|
|
1340
|
+
var CopyButton = {
|
|
1341
|
+
/** opts: { text, target (selector), label = 'Copy', copiedLabel = 'Copied', className, size: 'sm' } */
|
|
1342
|
+
render(opts) {
|
|
1343
|
+
opts = opts || {};
|
|
1344
|
+
const cls = "btn btn-outline-secondary mp-copy-btn" + (opts.size === "sm" ? " btn-sm" : "") + (opts.className ? " " + opts.className : "");
|
|
1345
|
+
return '<button type="button"' + attrs({
|
|
1346
|
+
class: cls,
|
|
1347
|
+
"data-mp-copy": opts.target ? void 0 : String(opts.text == null ? "" : opts.text),
|
|
1348
|
+
"data-mp-copy-target": opts.target,
|
|
1349
|
+
"data-mp-copied-label": opts.copiedLabel || "Copied",
|
|
1350
|
+
"aria-label": opts.ariaLabel
|
|
1351
|
+
}) + ">" + escapeHtml(opts.label || "Copy") + "</button>";
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
function copyFromButton(btn) {
|
|
1355
|
+
let text = btn.getAttribute("data-mp-copy");
|
|
1356
|
+
const targetSel = btn.getAttribute("data-mp-copy-target");
|
|
1357
|
+
if (targetSel) {
|
|
1358
|
+
const target = toElement(targetSel);
|
|
1359
|
+
if (!target) {
|
|
1360
|
+
return Promise.resolve(false);
|
|
1361
|
+
}
|
|
1362
|
+
text = "value" in target && target.tagName !== "BUTTON" ? target.value : target.textContent;
|
|
1363
|
+
}
|
|
1364
|
+
return copyText(text).then((ok) => {
|
|
1365
|
+
const label = btn.getAttribute("data-mp-copied-label") || "Copied";
|
|
1366
|
+
if (!btn.__mpLabel) {
|
|
1367
|
+
btn.__mpLabel = btn.textContent;
|
|
1368
|
+
}
|
|
1369
|
+
btn.textContent = ok ? label : "Copy failed";
|
|
1370
|
+
btn.classList.toggle("is-copied", ok);
|
|
1371
|
+
announce(ok ? "Copied to clipboard" : "Could not copy");
|
|
1372
|
+
clearTimeout(btn.__mpCopyTimer);
|
|
1373
|
+
btn.__mpCopyTimer = setTimeout(() => {
|
|
1374
|
+
btn.textContent = btn.__mpLabel;
|
|
1375
|
+
btn.classList.remove("is-copied");
|
|
1376
|
+
}, 2e3);
|
|
1377
|
+
return ok;
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
function toggleReveal(btn) {
|
|
1381
|
+
const input = doc().getElementById(btn.getAttribute("data-mp-reveal"));
|
|
1382
|
+
if (!input) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const show2 = input.type === "password";
|
|
1386
|
+
input.type = show2 ? "text" : "password";
|
|
1387
|
+
btn.setAttribute("aria-pressed", show2 ? "true" : "false");
|
|
1388
|
+
btn.textContent = show2 ? btn.getAttribute("data-mp-hide-label") || "Hide" : btn.getAttribute("data-mp-show-label") || "Show";
|
|
1389
|
+
}
|
|
1390
|
+
function normaliseOptions(options) {
|
|
1391
|
+
return (options || []).map((o) => o && typeof o === "object" ? o : { value: o, label: String(o) });
|
|
1392
|
+
}
|
|
1393
|
+
function fieldParts(opts, kind) {
|
|
1394
|
+
const id = opts.id || uid("mp-field");
|
|
1395
|
+
const helpId = opts.help ? id + "-help" : null;
|
|
1396
|
+
const errorId = opts.error ? id + "-error" : null;
|
|
1397
|
+
const describedBy = [helpId, errorId].filter(Boolean).join(" ") || void 0;
|
|
1398
|
+
const label = '<label class="' + (kind === "check" ? "form-check-label" : "form-label") + '" for="' + escapeHtml(id) + '">' + escapeHtml(opts.label || opts.name || "") + (opts.required ? '<span class="mp-required" aria-hidden="true"> *</span>' : "") + "</label>";
|
|
1399
|
+
const help = opts.help ? '<div class="form-text" id="' + escapeHtml(helpId) + '">' + escapeHtml(opts.help) + "</div>" : "";
|
|
1400
|
+
const error = opts.error ? '<div class="invalid-feedback d-block" id="' + escapeHtml(errorId) + '">' + escapeHtml(opts.error) + "</div>" : "";
|
|
1401
|
+
return { id, describedBy, label, help, error };
|
|
1402
|
+
}
|
|
1403
|
+
function field(opts) {
|
|
1404
|
+
opts = opts || {};
|
|
1405
|
+
const type = opts.type || "text";
|
|
1406
|
+
const check = type === "checkbox" || type === "switch";
|
|
1407
|
+
const p = fieldParts(opts, check ? "check" : "field");
|
|
1408
|
+
const common = {
|
|
1409
|
+
id: p.id,
|
|
1410
|
+
name: opts.name,
|
|
1411
|
+
required: !!opts.required,
|
|
1412
|
+
disabled: !!opts.disabled,
|
|
1413
|
+
"aria-describedby": p.describedBy,
|
|
1414
|
+
"aria-invalid": opts.error ? "true" : void 0
|
|
1415
|
+
};
|
|
1416
|
+
const invalid = opts.error ? " is-invalid" : "";
|
|
1417
|
+
if (check) {
|
|
1418
|
+
return '<div class="form-check' + (type === "switch" ? " form-switch" : "") + ' mb-3 mp-field"><input' + attrs(Object.assign({
|
|
1419
|
+
class: "form-check-input" + invalid,
|
|
1420
|
+
type: "checkbox",
|
|
1421
|
+
role: type === "switch" ? "switch" : void 0,
|
|
1422
|
+
checked: !!(opts.checked !== void 0 ? opts.checked : opts.value)
|
|
1423
|
+
}, common)) + ">" + p.label + p.help + p.error + "</div>";
|
|
1424
|
+
}
|
|
1425
|
+
let control;
|
|
1426
|
+
if (type === "select") {
|
|
1427
|
+
control = "<select" + attrs(Object.assign({ class: "form-select" + invalid, multiple: !!opts.multiple }, common)) + ">" + normaliseOptions(opts.options).map((o) => {
|
|
1428
|
+
const selected = Array.isArray(opts.value) ? opts.value.map(String).indexOf(String(o.value)) >= 0 : String(opts.value) === String(o.value);
|
|
1429
|
+
return "<option" + attrs({ value: String(o.value), selected, disabled: !!o.disabled }) + ">" + escapeHtml(o.label) + "</option>";
|
|
1430
|
+
}).join("") + "</select>";
|
|
1431
|
+
} else if (type === "textarea") {
|
|
1432
|
+
control = "<textarea" + attrs(Object.assign({
|
|
1433
|
+
class: "form-control" + invalid,
|
|
1434
|
+
rows: opts.rows || 3,
|
|
1435
|
+
placeholder: opts.placeholder,
|
|
1436
|
+
readonly: !!opts.readonly
|
|
1437
|
+
}, common)) + ">" + escapeHtml(opts.value == null ? "" : opts.value) + "</textarea>";
|
|
1438
|
+
} else {
|
|
1439
|
+
control = "<input" + attrs(Object.assign({
|
|
1440
|
+
class: "form-control" + invalid,
|
|
1441
|
+
type,
|
|
1442
|
+
value: opts.value == null ? void 0 : String(opts.value),
|
|
1443
|
+
placeholder: opts.placeholder,
|
|
1444
|
+
readonly: !!opts.readonly,
|
|
1445
|
+
min: opts.min,
|
|
1446
|
+
max: opts.max,
|
|
1447
|
+
step: opts.step,
|
|
1448
|
+
pattern: opts.pattern,
|
|
1449
|
+
autocomplete: opts.autocomplete,
|
|
1450
|
+
inputmode: opts.inputmode
|
|
1451
|
+
}, common)) + ">";
|
|
1452
|
+
}
|
|
1453
|
+
return '<div class="mb-3 mp-field">' + p.label + control + p.help + p.error + "</div>";
|
|
1454
|
+
}
|
|
1455
|
+
function secret(opts) {
|
|
1456
|
+
opts = opts || {};
|
|
1457
|
+
const p = fieldParts(opts, "field");
|
|
1458
|
+
const input = "<input" + attrs({
|
|
1459
|
+
class: "form-control mp-secret-input" + (opts.error ? " is-invalid" : ""),
|
|
1460
|
+
type: "password",
|
|
1461
|
+
id: p.id,
|
|
1462
|
+
name: opts.name,
|
|
1463
|
+
value: opts.value == null ? void 0 : String(opts.value),
|
|
1464
|
+
placeholder: opts.placeholder,
|
|
1465
|
+
required: !!opts.required,
|
|
1466
|
+
disabled: !!opts.disabled,
|
|
1467
|
+
readonly: !!opts.readonly,
|
|
1468
|
+
autocomplete: opts.autocomplete || "off",
|
|
1469
|
+
spellcheck: "false",
|
|
1470
|
+
autocapitalize: "off",
|
|
1471
|
+
"aria-describedby": p.describedBy,
|
|
1472
|
+
"aria-invalid": opts.error ? "true" : void 0
|
|
1473
|
+
}) + ">";
|
|
1474
|
+
const label = escapeHtml(opts.label || opts.name || "value");
|
|
1475
|
+
const reveal = opts.reveal === false ? "" : '<button type="button" class="btn btn-outline-secondary mp-reveal-btn" data-mp-reveal="' + escapeHtml(p.id) + '" aria-controls="' + escapeHtml(p.id) + '" aria-pressed="false" aria-label="Show ' + label + '">Show</button>';
|
|
1476
|
+
const copy = opts.copy === false ? "" : CopyButton.render({ target: "#" + p.id, ariaLabel: "Copy " + label });
|
|
1477
|
+
return '<div class="mb-3 mp-field">' + p.label + '<div class="input-group' + (opts.error ? " has-validation" : "") + '">' + input + reveal + copy + "</div>" + p.help + p.error + "</div>";
|
|
1478
|
+
}
|
|
1479
|
+
function setPath(obj, path, value) {
|
|
1480
|
+
const keys = path.split(".");
|
|
1481
|
+
let node = obj;
|
|
1482
|
+
keys.slice(0, -1).forEach((k) => {
|
|
1483
|
+
if (!node[k] || typeof node[k] !== "object") {
|
|
1484
|
+
node[k] = {};
|
|
1485
|
+
}
|
|
1486
|
+
node = node[k];
|
|
1487
|
+
});
|
|
1488
|
+
node[keys[keys.length - 1]] = value;
|
|
1489
|
+
}
|
|
1490
|
+
function getPath(obj, path) {
|
|
1491
|
+
return path.split(".").reduce((node, k) => node == null ? void 0 : node[k], obj);
|
|
1492
|
+
}
|
|
1493
|
+
function controls(form) {
|
|
1494
|
+
return Array.from(form.querySelectorAll("input[name], select[name], textarea[name]")).filter((el) => !el.disabled && !el.hasAttribute("data-mp-ignore") && ["button", "submit", "reset", "file", "image"].indexOf(el.type) < 0);
|
|
1495
|
+
}
|
|
1496
|
+
function values(form) {
|
|
1497
|
+
form = toElement(form);
|
|
1498
|
+
const out = {};
|
|
1499
|
+
const els = controls(form);
|
|
1500
|
+
const counts = {};
|
|
1501
|
+
els.forEach((el) => {
|
|
1502
|
+
if (el.type === "checkbox") {
|
|
1503
|
+
counts[el.name] = (counts[el.name] || 0) + 1;
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
els.forEach((el) => {
|
|
1507
|
+
const name = el.name;
|
|
1508
|
+
if (el.type === "checkbox") {
|
|
1509
|
+
if (counts[name] > 1) {
|
|
1510
|
+
const list = getPath(out, name) || [];
|
|
1511
|
+
if (el.checked) {
|
|
1512
|
+
list.push(el.value);
|
|
1513
|
+
}
|
|
1514
|
+
setPath(out, name, list);
|
|
1515
|
+
} else {
|
|
1516
|
+
setPath(out, name, el.checked);
|
|
1517
|
+
}
|
|
1518
|
+
} else if (el.type === "radio") {
|
|
1519
|
+
if (el.checked) {
|
|
1520
|
+
setPath(out, name, el.value);
|
|
1521
|
+
} else if (getPath(out, name) === void 0) {
|
|
1522
|
+
setPath(out, name, null);
|
|
1523
|
+
}
|
|
1524
|
+
} else if (el.type === "number" || el.type === "range") {
|
|
1525
|
+
setPath(out, name, el.value === "" ? null : Number(el.value));
|
|
1526
|
+
} else if (el.tagName === "SELECT" && el.multiple) {
|
|
1527
|
+
setPath(out, name, Array.from(el.selectedOptions).map((o) => o.value));
|
|
1528
|
+
} else {
|
|
1529
|
+
setPath(out, name, el.value);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
return out;
|
|
1533
|
+
}
|
|
1534
|
+
function fill(form, data) {
|
|
1535
|
+
form = toElement(form);
|
|
1536
|
+
data = data || {};
|
|
1537
|
+
controls(form).forEach((el) => {
|
|
1538
|
+
const v = getPath(data, el.name);
|
|
1539
|
+
if (v === void 0) {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (el.type === "checkbox") {
|
|
1543
|
+
el.checked = Array.isArray(v) ? v.map(String).indexOf(el.value) >= 0 : !!v;
|
|
1544
|
+
} else if (el.type === "radio") {
|
|
1545
|
+
el.checked = String(v) === el.value;
|
|
1546
|
+
} else if (el.tagName === "SELECT" && el.multiple) {
|
|
1547
|
+
const list = (Array.isArray(v) ? v : [v]).map(String);
|
|
1548
|
+
Array.from(el.options).forEach((o) => {
|
|
1549
|
+
o.selected = list.indexOf(o.value) >= 0;
|
|
1550
|
+
});
|
|
1551
|
+
} else {
|
|
1552
|
+
el.value = v == null ? "" : String(v);
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
return form;
|
|
1556
|
+
}
|
|
1557
|
+
function isPlainObject(v) {
|
|
1558
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
1559
|
+
}
|
|
1560
|
+
function mergeConfig(base, patch) {
|
|
1561
|
+
const out = Object.assign({}, base);
|
|
1562
|
+
Object.keys(patch || {}).forEach((k) => {
|
|
1563
|
+
out[k] = isPlainObject(out[k]) && isPlainObject(patch[k]) ? mergeConfig(out[k], patch[k]) : patch[k];
|
|
1564
|
+
});
|
|
1565
|
+
return out;
|
|
1566
|
+
}
|
|
1567
|
+
function track(form, opts) {
|
|
1568
|
+
opts = opts || {};
|
|
1569
|
+
form = toElement(form);
|
|
1570
|
+
const d = form.ownerDocument;
|
|
1571
|
+
let baseline = JSON.stringify(values(form));
|
|
1572
|
+
let baselineValues = values(form);
|
|
1573
|
+
let dirty = false;
|
|
1574
|
+
let busy = false;
|
|
1575
|
+
let bar = null;
|
|
1576
|
+
if (opts.bar !== false) {
|
|
1577
|
+
bar = d.createElement("div");
|
|
1578
|
+
bar.className = "mp-savebar";
|
|
1579
|
+
bar.setAttribute("role", "region");
|
|
1580
|
+
bar.setAttribute("aria-label", "Unsaved changes");
|
|
1581
|
+
bar.hidden = true;
|
|
1582
|
+
bar.innerHTML = '<span class="mp-savebar-message" role="status">' + escapeHtml(opts.message || "You have unsaved changes") + '</span><span class="mp-savebar-actions"><button type="button" class="btn btn-sm btn-outline-secondary" data-mp-save="discard">' + escapeHtml(opts.discardLabel || "Discard") + '</button><button type="button" class="btn btn-sm btn-primary" data-mp-save="save">' + escapeHtml(opts.saveLabel || "Save") + "</button></span>";
|
|
1583
|
+
const host = opts.bar && opts.bar !== true ? toElement(opts.bar) : null;
|
|
1584
|
+
if (host) {
|
|
1585
|
+
host.appendChild(bar);
|
|
1586
|
+
} else {
|
|
1587
|
+
form.insertAdjacentElement("afterend", bar);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function setDirty(next) {
|
|
1591
|
+
if (next === dirty) {
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
dirty = next;
|
|
1595
|
+
if (bar) {
|
|
1596
|
+
bar.hidden = !dirty;
|
|
1597
|
+
}
|
|
1598
|
+
form.classList.toggle("is-dirty", dirty);
|
|
1599
|
+
if (typeof opts.onDirtyChange === "function") {
|
|
1600
|
+
opts.onDirtyChange(dirty);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function check() {
|
|
1604
|
+
setDirty(JSON.stringify(values(form)) !== baseline);
|
|
1605
|
+
}
|
|
1606
|
+
function reset() {
|
|
1607
|
+
baselineValues = values(form);
|
|
1608
|
+
baseline = JSON.stringify(baselineValues);
|
|
1609
|
+
setDirty(false);
|
|
1610
|
+
}
|
|
1611
|
+
function setBusy(next) {
|
|
1612
|
+
busy = next;
|
|
1613
|
+
if (!bar) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
bar.setAttribute("aria-busy", next ? "true" : "false");
|
|
1617
|
+
bar.querySelectorAll("button").forEach((b) => {
|
|
1618
|
+
b.disabled = next;
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
async function defaultSave(vals) {
|
|
1622
|
+
const hb = hbClient();
|
|
1623
|
+
if (!hb || typeof hb.updatePluginConfig !== "function") {
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
const blocks = typeof hb.getPluginConfig === "function" ? await hb.getPluginConfig() || [] : [];
|
|
1627
|
+
let config = typeof opts.toConfig === "function" ? await opts.toConfig(vals, blocks) : [mergeConfig(blocks[0] || {}, vals)].concat(blocks.slice(1));
|
|
1628
|
+
if (!Array.isArray(config)) {
|
|
1629
|
+
config = [config];
|
|
1630
|
+
}
|
|
1631
|
+
await hb.updatePluginConfig(config);
|
|
1632
|
+
if (opts.persist !== false && typeof hb.savePluginConfig === "function") {
|
|
1633
|
+
await hb.savePluginConfig();
|
|
1634
|
+
}
|
|
1635
|
+
return config;
|
|
1636
|
+
}
|
|
1637
|
+
async function save() {
|
|
1638
|
+
if (busy) {
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
const vals = values(form);
|
|
1642
|
+
setBusy(true);
|
|
1643
|
+
try {
|
|
1644
|
+
const custom = typeof opts.save === "function";
|
|
1645
|
+
const result = custom ? await opts.save(vals) : await defaultSave(vals);
|
|
1646
|
+
const saved = custom || result !== null;
|
|
1647
|
+
reset();
|
|
1648
|
+
if (saved) {
|
|
1649
|
+
if (opts.toast !== false) {
|
|
1650
|
+
Toast.success(opts.savedMessage || "Settings saved");
|
|
1651
|
+
}
|
|
1652
|
+
if (typeof opts.onSaved === "function") {
|
|
1653
|
+
opts.onSaved(result);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return saved;
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
if (opts.toast !== false) {
|
|
1659
|
+
Toast.error(errorMessage(err, "Could not save the settings"));
|
|
1660
|
+
}
|
|
1661
|
+
if (typeof opts.onError === "function") {
|
|
1662
|
+
opts.onError(err);
|
|
1663
|
+
}
|
|
1664
|
+
throw err;
|
|
1665
|
+
} finally {
|
|
1666
|
+
setBusy(false);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
function discard() {
|
|
1670
|
+
fill(form, baselineValues);
|
|
1671
|
+
check();
|
|
1672
|
+
}
|
|
1673
|
+
function onBarClick(ev) {
|
|
1674
|
+
const btn = ev.target.closest("[data-mp-save]");
|
|
1675
|
+
if (!btn) {
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (btn.getAttribute("data-mp-save") === "save") {
|
|
1679
|
+
save().catch(() => {
|
|
1680
|
+
});
|
|
1681
|
+
} else {
|
|
1682
|
+
discard();
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function onSubmit(ev) {
|
|
1686
|
+
ev.preventDefault();
|
|
1687
|
+
save().catch(() => {
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
form.addEventListener("input", check);
|
|
1691
|
+
form.addEventListener("change", check);
|
|
1692
|
+
if (opts.submit !== false) {
|
|
1693
|
+
form.addEventListener("submit", onSubmit);
|
|
1694
|
+
}
|
|
1695
|
+
if (bar) {
|
|
1696
|
+
bar.addEventListener("click", onBarClick);
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
bar,
|
|
1700
|
+
isDirty: () => dirty,
|
|
1701
|
+
values: () => values(form),
|
|
1702
|
+
save,
|
|
1703
|
+
discard,
|
|
1704
|
+
/** Takes the current values as the new saved state. */
|
|
1705
|
+
reset,
|
|
1706
|
+
/** Re-checks after programmatic changes. */
|
|
1707
|
+
check,
|
|
1708
|
+
destroy() {
|
|
1709
|
+
form.removeEventListener("input", check);
|
|
1710
|
+
form.removeEventListener("change", check);
|
|
1711
|
+
form.removeEventListener("submit", onSubmit);
|
|
1712
|
+
if (bar) {
|
|
1713
|
+
bar.remove();
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
var Form = { field, secret, values, fill, track };
|
|
1719
|
+
|
|
1720
|
+
// src/js/delegates.js
|
|
1721
|
+
var ACTIVATABLE = '.mp-device-card[role="button"], [data-mp-activate][role="button"]';
|
|
1722
|
+
function onClick(ev) {
|
|
1723
|
+
const t = ev.target;
|
|
1724
|
+
if (!t || typeof t.closest !== "function") {
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const copy = t.closest("[data-mp-copy], [data-mp-copy-target]");
|
|
1728
|
+
if (copy) {
|
|
1729
|
+
copyFromButton(copy);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const reveal = t.closest("[data-mp-reveal]");
|
|
1733
|
+
if (reveal) {
|
|
1734
|
+
toggleReveal(reveal);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
function onKeydown(ev) {
|
|
1738
|
+
if (ev.defaultPrevented || ev.key !== "Enter" && ev.key !== " ") {
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const t = ev.target;
|
|
1742
|
+
if (!t || typeof t.matches !== "function" || !t.matches(ACTIVATABLE)) {
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
ev.preventDefault();
|
|
1746
|
+
t.click();
|
|
1747
|
+
}
|
|
1748
|
+
function installDelegates() {
|
|
1749
|
+
const d = doc();
|
|
1750
|
+
if (!d || typeof d.addEventListener !== "function" || d.__mpKitDelegates) {
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
d.__mpKitDelegates = true;
|
|
1754
|
+
d.addEventListener("click", onClick);
|
|
1755
|
+
d.addEventListener("keydown", onKeydown);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// src/js/devices.js
|
|
1759
|
+
var Skeleton = {
|
|
1760
|
+
/** opts: { lines = 3, avatar = false, className } */
|
|
1761
|
+
render(opts) {
|
|
1762
|
+
opts = opts || {};
|
|
1763
|
+
const lines = Math.max(1, opts.lines || 3);
|
|
1764
|
+
let html = "";
|
|
1765
|
+
for (let i = 0; i < lines; i += 1) {
|
|
1766
|
+
html += '<span class="mp-skeleton mp-skeleton-text"' + (i === lines - 1 && lines > 1 ? ' style="width:60%"' : "") + "></span>";
|
|
1767
|
+
}
|
|
1768
|
+
return '<div class="mp-skeleton-group' + (opts.className ? " " + escapeHtml(opts.className) : "") + '" aria-hidden="true">' + (opts.avatar ? '<span class="mp-skeleton mp-skeleton-circle"></span>' : "") + '<span class="mp-skeleton-lines">' + html + "</span></div>";
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
var STATUS = {
|
|
1772
|
+
online: StatusBadge.online,
|
|
1773
|
+
offline: StatusBadge.offline,
|
|
1774
|
+
checking: StatusBadge.checking,
|
|
1775
|
+
disabled: StatusBadge.disabled
|
|
1776
|
+
};
|
|
1777
|
+
function defaultItem(item, index, interactive) {
|
|
1778
|
+
const name = item.name || item.displayName || item.id || "Device " + (index + 1);
|
|
1779
|
+
const status = STATUS[item.status] ? STATUS[item.status](item.statusLabel) : "";
|
|
1780
|
+
return '<div class="card mp-device-card"' + (interactive ? ' role="button" tabindex="0"' : "") + '><div class="card-body mp-device-card-body">' + (item.iconClass ? '<i class="' + escapeHtml(item.iconClass) + ' mp-device-card-icon" aria-hidden="true"></i>' : "") + '<div class="mp-device-card-text"><div class="mp-device-card-name">' + escapeHtml(name) + "</div>" + (item.subtitle ? '<div class="mp-device-card-subtitle">' + escapeHtml(item.subtitle) + "</div>" : "") + "</div>" + status + "</div></div>";
|
|
1781
|
+
}
|
|
1782
|
+
function defaultFilter(keys) {
|
|
1783
|
+
return (item, query) => keys.some((k) => {
|
|
1784
|
+
const v = item && item[k];
|
|
1785
|
+
return v != null && String(v).toLowerCase().indexOf(query) >= 0;
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
var DeviceList = {
|
|
1789
|
+
/**
|
|
1790
|
+
* Renders a device list into el and returns a controller.
|
|
1791
|
+
* opts: { items = [], loading = false, label = 'Devices',
|
|
1792
|
+
* renderItem(item, index) → HTML (default: an .mp-device-card with
|
|
1793
|
+
* name/displayName, subtitle, iconClass and status badge),
|
|
1794
|
+
* search = true | { placeholder, label }, keys = ['name', 'subtitle', 'id'],
|
|
1795
|
+
* filter(item, query), onActivate(item, index, event),
|
|
1796
|
+
* empty: { iconClass, title, hint }, noResults: { title, hint },
|
|
1797
|
+
* skeletonCount = 3 }
|
|
1798
|
+
* With onActivate the default cards are role="button" (Enter / Space work).
|
|
1799
|
+
* Returns { setItems(items), setLoading(bool), setQuery(q), items(), visible(), destroy() }.
|
|
1800
|
+
*/
|
|
1801
|
+
render(el, opts) {
|
|
1802
|
+
opts = opts || {};
|
|
1803
|
+
el = toElement(el);
|
|
1804
|
+
const label = opts.label || "Devices";
|
|
1805
|
+
const searchOpts = opts.search === false ? null : typeof opts.search === "object" ? opts.search : {};
|
|
1806
|
+
const searchId = uid("mp-device-search");
|
|
1807
|
+
const filter = typeof opts.filter === "function" ? opts.filter : defaultFilter(opts.keys || ["name", "displayName", "subtitle", "id"]);
|
|
1808
|
+
const interactive = typeof opts.onActivate === "function";
|
|
1809
|
+
let items = Array.isArray(opts.items) ? opts.items.slice() : [];
|
|
1810
|
+
let loading = !!opts.loading;
|
|
1811
|
+
let query = "";
|
|
1812
|
+
let visible = [];
|
|
1813
|
+
el.innerHTML = '<div class="mp-device-list">' + (searchOpts ? '<div class="mp-device-list-search"><label class="mp-sr-only" for="' + searchId + '">' + escapeHtml(searchOpts.label || "Search " + label.toLowerCase()) + '</label><input type="search" class="form-control" id="' + searchId + '" autocomplete="off" placeholder="' + escapeHtml(searchOpts.placeholder || "Search…") + '"></div>' : "") + '<p class="mp-sr-only" role="status" aria-live="polite" aria-atomic="true"></p><div class="mp-device-list-items" role="list" aria-label="' + escapeHtml(label) + '"></div></div>';
|
|
1814
|
+
const list = el.querySelector(".mp-device-list-items");
|
|
1815
|
+
const status = el.querySelector('.mp-device-list [role="status"]');
|
|
1816
|
+
const input = searchOpts ? el.querySelector('input[type="search"]') : null;
|
|
1817
|
+
function announce2(text) {
|
|
1818
|
+
if (status.textContent !== text) {
|
|
1819
|
+
status.textContent = text;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
function paint() {
|
|
1823
|
+
list.setAttribute("aria-busy", loading ? "true" : "false");
|
|
1824
|
+
if (input) {
|
|
1825
|
+
input.disabled = loading && !items.length;
|
|
1826
|
+
}
|
|
1827
|
+
if (loading && !items.length) {
|
|
1828
|
+
const n = opts.skeletonCount || 3;
|
|
1829
|
+
let html = "";
|
|
1830
|
+
for (let i = 0; i < n; i += 1) {
|
|
1831
|
+
html += '<div class="card mp-device-card mp-device-card-skeleton" role="listitem"><div class="card-body">' + Skeleton.render({ lines: 2, avatar: true }) + "</div></div>";
|
|
1832
|
+
}
|
|
1833
|
+
list.innerHTML = html;
|
|
1834
|
+
announce2("Loading " + label.toLowerCase() + "…");
|
|
1835
|
+
visible = [];
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
visible = query ? items.filter((item) => filter(item, query)) : items.slice();
|
|
1839
|
+
if (!items.length) {
|
|
1840
|
+
list.innerHTML = '<div role="listitem">' + EmptyState.render(Object.assign({ title: "No " + label.toLowerCase() + " found" }, opts.empty)) + "</div>";
|
|
1841
|
+
announce2("No " + label.toLowerCase());
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (!visible.length) {
|
|
1845
|
+
const nr = opts.noResults || {};
|
|
1846
|
+
list.innerHTML = '<div role="listitem">' + EmptyState.render({
|
|
1847
|
+
iconClass: nr.iconClass || "bi bi-search",
|
|
1848
|
+
title: nr.title || "No matches for “" + query + "”",
|
|
1849
|
+
hint: nr.hint || "Try a different search"
|
|
1850
|
+
}) + "</div>";
|
|
1851
|
+
announce2("No matches");
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
list.innerHTML = visible.map((item) => {
|
|
1855
|
+
const index = items.indexOf(item);
|
|
1856
|
+
const html = typeof opts.renderItem === "function" ? opts.renderItem(item, index) : defaultItem(item, index, interactive);
|
|
1857
|
+
return '<div role="listitem" class="mp-device-list-item" data-mp-index="' + index + '">' + html + "</div>";
|
|
1858
|
+
}).join("");
|
|
1859
|
+
announce2(query ? visible.length + " of " + items.length + " " + label.toLowerCase() : "");
|
|
1860
|
+
}
|
|
1861
|
+
function onInput() {
|
|
1862
|
+
query = input.value.trim().toLowerCase();
|
|
1863
|
+
paint();
|
|
1864
|
+
}
|
|
1865
|
+
function onClick2(ev) {
|
|
1866
|
+
if (!interactive) {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const row = ev.target.closest("[data-mp-index]");
|
|
1870
|
+
if (!row || !list.contains(row)) {
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const control = ev.target.closest('a, button, input, select, textarea, label, [role="button"]');
|
|
1874
|
+
if (control && row.contains(control) && !control.classList.contains("mp-device-card")) {
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
const index = Number(row.getAttribute("data-mp-index"));
|
|
1878
|
+
opts.onActivate(items[index], index, ev);
|
|
1879
|
+
}
|
|
1880
|
+
if (input) {
|
|
1881
|
+
input.addEventListener("input", onInput);
|
|
1882
|
+
}
|
|
1883
|
+
list.addEventListener("click", onClick2);
|
|
1884
|
+
paint();
|
|
1885
|
+
return {
|
|
1886
|
+
setItems(next) {
|
|
1887
|
+
items = Array.isArray(next) ? next.slice() : [];
|
|
1888
|
+
loading = false;
|
|
1889
|
+
paint();
|
|
1890
|
+
},
|
|
1891
|
+
setLoading(next) {
|
|
1892
|
+
loading = !!next;
|
|
1893
|
+
paint();
|
|
1894
|
+
},
|
|
1895
|
+
setQuery(q) {
|
|
1896
|
+
query = String(q == null ? "" : q).trim().toLowerCase();
|
|
1897
|
+
if (input) {
|
|
1898
|
+
input.value = q == null ? "" : String(q);
|
|
1899
|
+
}
|
|
1900
|
+
paint();
|
|
1901
|
+
},
|
|
1902
|
+
items: () => items.slice(),
|
|
1903
|
+
visible: () => visible.slice(),
|
|
1904
|
+
/** Text announced to screen readers for the current state. */
|
|
1905
|
+
status: () => textOf(status),
|
|
1906
|
+
destroy() {
|
|
1907
|
+
if (input) {
|
|
1908
|
+
input.removeEventListener("input", onInput);
|
|
1909
|
+
}
|
|
1910
|
+
list.removeEventListener("click", onClick2);
|
|
1911
|
+
el.innerHTML = "";
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
|
|
1917
|
+
// src/js/log.js
|
|
1918
|
+
var ANSI = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
1919
|
+
function stripAnsi(text) {
|
|
1920
|
+
return String(text == null ? "" : text).replace(ANSI, "");
|
|
1921
|
+
}
|
|
1922
|
+
var NEAR_BOTTOM = 8;
|
|
1923
|
+
var LogViewer = {
|
|
1924
|
+
/**
|
|
1925
|
+
* Creates a log viewer in el.
|
|
1926
|
+
* opts: { maxLines = 1000, label = 'Log', live = 'off' | 'polite',
|
|
1927
|
+
* autoscroll = true, wrap = false, lineClass(line) → extra class,
|
|
1928
|
+
* jumpLabel = 'Jump to latest' }
|
|
1929
|
+
* Returns { append(textOrLines), clear(), lines(), scrollToBottom(), isFollowing(), destroy(), element }.
|
|
1930
|
+
*/
|
|
1931
|
+
create(el, opts) {
|
|
1932
|
+
opts = opts || {};
|
|
1933
|
+
el = toElement(el);
|
|
1934
|
+
const d = el.ownerDocument;
|
|
1935
|
+
const maxLines = opts.maxLines > 0 ? opts.maxLines : 1e3;
|
|
1936
|
+
el.innerHTML = '<div class="mp-log' + (opts.wrap ? " mp-log-wrap" : "") + '"><div class="mp-log-body" role="log" tabindex="0"></div><button type="button" class="btn btn-sm btn-primary mp-log-jump" hidden></button></div>';
|
|
1937
|
+
const body = el.querySelector(".mp-log-body");
|
|
1938
|
+
const jump = el.querySelector(".mp-log-jump");
|
|
1939
|
+
body.setAttribute("aria-label", opts.label || "Log");
|
|
1940
|
+
body.setAttribute("aria-live", opts.live === "polite" ? "polite" : "off");
|
|
1941
|
+
jump.textContent = opts.jumpLabel || "Jump to latest";
|
|
1942
|
+
let follow = opts.autoscroll !== false;
|
|
1943
|
+
let lines = [];
|
|
1944
|
+
function atBottom() {
|
|
1945
|
+
return body.scrollHeight - body.scrollTop - body.clientHeight <= NEAR_BOTTOM;
|
|
1946
|
+
}
|
|
1947
|
+
function scrollToBottom() {
|
|
1948
|
+
body.scrollTop = body.scrollHeight;
|
|
1949
|
+
follow = opts.autoscroll !== false;
|
|
1950
|
+
jump.hidden = true;
|
|
1951
|
+
}
|
|
1952
|
+
function onScroll() {
|
|
1953
|
+
if (opts.autoscroll === false) {
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
follow = atBottom();
|
|
1957
|
+
if (follow) {
|
|
1958
|
+
jump.hidden = true;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
function append(input) {
|
|
1962
|
+
const incoming = (Array.isArray(input) ? input : String(input == null ? "" : input).split(/\r?\n/)).map(stripAnsi);
|
|
1963
|
+
if (!Array.isArray(input) && incoming.length > 1 && incoming[incoming.length - 1] === "") {
|
|
1964
|
+
incoming.pop();
|
|
1965
|
+
}
|
|
1966
|
+
if (!incoming.length) {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const stick = follow && opts.autoscroll !== false;
|
|
1970
|
+
const frag = d.createDocumentFragment();
|
|
1971
|
+
incoming.forEach((line) => {
|
|
1972
|
+
const row = d.createElement("div");
|
|
1973
|
+
const extra = typeof opts.lineClass === "function" ? opts.lineClass(line) : "";
|
|
1974
|
+
row.className = "mp-log-line" + (extra ? " " + extra : "");
|
|
1975
|
+
row.textContent = line;
|
|
1976
|
+
frag.appendChild(row);
|
|
1977
|
+
});
|
|
1978
|
+
body.appendChild(frag);
|
|
1979
|
+
lines = lines.concat(incoming);
|
|
1980
|
+
const excess = lines.length - maxLines;
|
|
1981
|
+
if (excess > 0) {
|
|
1982
|
+
lines = lines.slice(excess);
|
|
1983
|
+
for (let i = 0; i < excess && body.firstChild; i += 1) {
|
|
1984
|
+
body.removeChild(body.firstChild);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (stick) {
|
|
1988
|
+
body.scrollTop = body.scrollHeight;
|
|
1989
|
+
} else if (opts.autoscroll !== false) {
|
|
1990
|
+
jump.hidden = false;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
body.addEventListener("scroll", onScroll);
|
|
1994
|
+
jump.addEventListener("click", () => {
|
|
1995
|
+
scrollToBottom();
|
|
1996
|
+
body.focus();
|
|
1997
|
+
});
|
|
1998
|
+
return {
|
|
1999
|
+
element: body,
|
|
2000
|
+
append,
|
|
2001
|
+
clear() {
|
|
2002
|
+
lines = [];
|
|
2003
|
+
body.textContent = "";
|
|
2004
|
+
jump.hidden = true;
|
|
2005
|
+
},
|
|
2006
|
+
lines: () => lines.slice(),
|
|
2007
|
+
scrollToBottom,
|
|
2008
|
+
isFollowing: () => follow,
|
|
2009
|
+
destroy() {
|
|
2010
|
+
body.removeEventListener("scroll", onScroll);
|
|
2011
|
+
el.innerHTML = "";
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
},
|
|
2015
|
+
stripAnsi
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// src/js/pairing.js
|
|
2019
|
+
function formatPin(code) {
|
|
2020
|
+
const digits = String(code == null ? "" : code).replace(/\D/g, "");
|
|
2021
|
+
return digits.length === 8 ? digits.slice(0, 3) + "-" + digits.slice(3, 5) + "-" + digits.slice(5) : "";
|
|
2022
|
+
}
|
|
2023
|
+
function safeImageSrc(src) {
|
|
2024
|
+
if (typeof src !== "string") {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
const s = src.trim();
|
|
2028
|
+
if (safeUrl(s)) {
|
|
2029
|
+
return s;
|
|
2030
|
+
}
|
|
2031
|
+
if (/^data:image\/(png|gif|jpeg|webp|svg\+xml)[;,]/i.test(s)) {
|
|
2032
|
+
return s;
|
|
2033
|
+
}
|
|
2034
|
+
if (/^(\.{0,2}\/|[\w-]+(\/|\.))/.test(s) && !/^[a-z][\w+.-]*:/i.test(s)) {
|
|
2035
|
+
return s;
|
|
2036
|
+
}
|
|
2037
|
+
return null;
|
|
2038
|
+
}
|
|
2039
|
+
function pinHtml(opts) {
|
|
2040
|
+
opts = opts || {};
|
|
2041
|
+
const formatted = formatPin(opts.pin) || escapeHtml(opts.pin || "");
|
|
2042
|
+
const spoken = String(opts.pin == null ? "" : opts.pin).replace(/\D/g, "").split("").join(" ");
|
|
2043
|
+
const id = uid("mp-pin");
|
|
2044
|
+
const src = safeImageSrc(opts.qrSrc);
|
|
2045
|
+
const label = opts.label || "HomeKit setup code";
|
|
2046
|
+
return '<figure class="mp-pin" aria-labelledby="' + id + '"><div class="mp-pin-label-box"><span class="mp-pin-label" id="' + id + '">' + escapeHtml(label) + '</span><span class="mp-pin-code" aria-hidden="true">' + formatted + '</span><span class="mp-sr-only">' + escapeHtml(spoken || formatted) + "</span></div>" + (src ? '<img class="mp-pin-qr" src="' + escapeHtml(src) + '" alt="' + escapeHtml(opts.qrAlt || "QR code for " + label) + '">' : "") + '<span class="mp-pin-qr-slot"></span>' + (opts.copy === false ? "" : '<figcaption class="mp-pin-actions">' + CopyButton.render({ text: formatted, label: opts.copyLabel || "Copy code", size: "sm" }) + "</figcaption>") + "</figure>";
|
|
2047
|
+
}
|
|
2048
|
+
var Pairing = {
|
|
2049
|
+
formatPin,
|
|
2050
|
+
/**
|
|
2051
|
+
* HTML for a HomeKit setup-code block (XXX-XX-XXX, read digit by digit by
|
|
2052
|
+
* screen readers) with an optional QR image and a Copy button.
|
|
2053
|
+
* opts: { pin, label = 'HomeKit setup code', qrSrc (http(s), path or
|
|
2054
|
+
* data:image/*), qrAlt, copy = true, copyLabel }
|
|
2055
|
+
* The kit does not generate QR codes: pass an image, or an SVG element to renderPin().
|
|
2056
|
+
*/
|
|
2057
|
+
pin: pinHtml,
|
|
2058
|
+
/** Renders pin() into el; opts.qr may be an SVG/IMG element (appended) or an image URL. */
|
|
2059
|
+
renderPin(el, opts) {
|
|
2060
|
+
opts = opts || {};
|
|
2061
|
+
el = toElement(el);
|
|
2062
|
+
const qr = opts.qr;
|
|
2063
|
+
el.innerHTML = pinHtml(Object.assign({}, opts, { qrSrc: typeof qr === "string" ? qr : opts.qrSrc }));
|
|
2064
|
+
if (qr && typeof qr === "object" && qr.nodeType === 1) {
|
|
2065
|
+
const slot = el.querySelector(".mp-pin-qr-slot");
|
|
2066
|
+
if (qr.tagName.toLowerCase() === "svg") {
|
|
2067
|
+
qr.setAttribute("role", "img");
|
|
2068
|
+
if (!qr.getAttribute("aria-label")) {
|
|
2069
|
+
qr.setAttribute("aria-label", opts.qrAlt || "QR code for " + (opts.label || "HomeKit setup code"));
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
qr.classList.add("mp-pin-qr");
|
|
2073
|
+
slot.appendChild(qr);
|
|
2074
|
+
}
|
|
2075
|
+
return el.querySelector(".mp-pin");
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
var STATE_TEXT = {
|
|
2079
|
+
waiting: "Waiting for you to sign in…",
|
|
2080
|
+
polling: "Waiting for authorization…",
|
|
2081
|
+
success: "Signed in.",
|
|
2082
|
+
error: "Sign-in failed.",
|
|
2083
|
+
expired: "The code expired. Start again to get a new one."
|
|
2084
|
+
};
|
|
2085
|
+
var Auth = {
|
|
2086
|
+
/**
|
|
2087
|
+
* Renders a device-code / OAuth step card into el:
|
|
2088
|
+
* 1. open the sign-in URL, 2. enter the code (with Copy), then a status
|
|
2089
|
+
* line that follows the polling state.
|
|
2090
|
+
* opts: { title = 'Sign in', url, code, instructions, openLabel,
|
|
2091
|
+
* poll() → true | { done, error, interval } (called every `interval` ms
|
|
2092
|
+
* until done; a rejection or { error } shows the error),
|
|
2093
|
+
* interval = 5000, expiresIn (seconds), onSuccess(result), onError(err),
|
|
2094
|
+
* onRetry() (shows a Try again button on error/expiry), successMessage }
|
|
2095
|
+
* Returns { setState(state, message), state(), start(), stop(), destroy(), element }.
|
|
2096
|
+
*/
|
|
2097
|
+
deviceCode(el, opts) {
|
|
2098
|
+
opts = opts || {};
|
|
2099
|
+
el = toElement(el);
|
|
2100
|
+
const titleId = uid("mp-auth-title");
|
|
2101
|
+
const url = safeUrl(opts.url);
|
|
2102
|
+
el.innerHTML = '<section class="card mp-auth-card" aria-labelledby="' + titleId + '"><div class="card-body"><h3 class="mp-auth-title" id="' + titleId + '">' + escapeHtml(opts.title || "Sign in") + "</h3>" + (opts.instructions ? '<p class="mp-auth-instructions">' + escapeHtml(opts.instructions) + "</p>" : "") + '<ol class="mp-auth-steps">' + (url ? '<li><span class="mp-auth-step-text">Open the sign-in page</span><a class="btn btn-primary btn-sm mp-auth-open" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(opts.openLabel || "Open sign-in page") + '<span class="mp-sr-only"> (opens in a new tab)</span></a></li>' : "") + (opts.code ? '<li><span class="mp-auth-step-text">Enter this code</span><span class="mp-auth-code-row"><code class="mp-auth-code">' + escapeHtml(opts.code) + "</code>" + CopyButton.render({ text: opts.code, label: "Copy", size: "sm", ariaLabel: "Copy code" }) + "</span></li>" : "") + '</ol><p class="mp-auth-status" role="status" aria-live="polite"><span class="mp-auth-status-icon" aria-hidden="true"></span><span class="mp-auth-status-text"></span></p><button type="button" class="btn btn-outline-secondary btn-sm mp-auth-retry" hidden>Try again</button></div></section>';
|
|
2103
|
+
const card = el.querySelector(".mp-auth-card");
|
|
2104
|
+
const statusEl = el.querySelector(".mp-auth-status");
|
|
2105
|
+
const statusText = el.querySelector(".mp-auth-status-text");
|
|
2106
|
+
const retry = el.querySelector(".mp-auth-retry");
|
|
2107
|
+
let state = null;
|
|
2108
|
+
let timer = null;
|
|
2109
|
+
let expiry = null;
|
|
2110
|
+
let running = false;
|
|
2111
|
+
let interval = opts.interval > 0 ? opts.interval : 5e3;
|
|
2112
|
+
function setState(next, message) {
|
|
2113
|
+
state = STATE_TEXT[next] ? next : "waiting";
|
|
2114
|
+
["waiting", "polling", "success", "error", "expired"].forEach((s) => card.classList.toggle("is-" + s, s === state));
|
|
2115
|
+
statusText.textContent = message || state === "success" && opts.successMessage || STATE_TEXT[state];
|
|
2116
|
+
statusEl.setAttribute("role", state === "error" || state === "expired" ? "alert" : "status");
|
|
2117
|
+
retry.hidden = !((state === "error" || state === "expired") && typeof opts.onRetry === "function");
|
|
2118
|
+
if (state === "success" || state === "error" || state === "expired") {
|
|
2119
|
+
stop();
|
|
2120
|
+
}
|
|
2121
|
+
return controller;
|
|
2122
|
+
}
|
|
2123
|
+
function stop() {
|
|
2124
|
+
running = false;
|
|
2125
|
+
clearTimeout(timer);
|
|
2126
|
+
clearTimeout(expiry);
|
|
2127
|
+
}
|
|
2128
|
+
function schedule() {
|
|
2129
|
+
if (!running) {
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
timer = setTimeout(tick, interval);
|
|
2133
|
+
}
|
|
2134
|
+
function tick() {
|
|
2135
|
+
if (!running) {
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
Promise.resolve().then(() => opts.poll()).then((res) => {
|
|
2139
|
+
if (!running) {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (res && typeof res === "object" && res.error) {
|
|
2143
|
+
throw res.error;
|
|
2144
|
+
}
|
|
2145
|
+
if (res && typeof res === "object" && res.interval > 0) {
|
|
2146
|
+
interval = res.interval;
|
|
2147
|
+
}
|
|
2148
|
+
if (res === true || res && typeof res === "object" && res.done) {
|
|
2149
|
+
setState("success");
|
|
2150
|
+
if (typeof opts.onSuccess === "function") {
|
|
2151
|
+
opts.onSuccess(res);
|
|
2152
|
+
}
|
|
2153
|
+
} else {
|
|
2154
|
+
schedule();
|
|
2155
|
+
}
|
|
2156
|
+
}).catch((err) => {
|
|
2157
|
+
if (!running) {
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
setState("error", err && err.message || (typeof err === "string" ? err : ""));
|
|
2161
|
+
if (typeof opts.onError === "function") {
|
|
2162
|
+
opts.onError(err);
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
function start() {
|
|
2167
|
+
if (typeof opts.poll !== "function") {
|
|
2168
|
+
return controller;
|
|
2169
|
+
}
|
|
2170
|
+
stop();
|
|
2171
|
+
running = true;
|
|
2172
|
+
setState("polling");
|
|
2173
|
+
if (opts.expiresIn > 0) {
|
|
2174
|
+
expiry = setTimeout(() => setState("expired"), opts.expiresIn * 1e3);
|
|
2175
|
+
}
|
|
2176
|
+
schedule();
|
|
2177
|
+
return controller;
|
|
2178
|
+
}
|
|
2179
|
+
retry.addEventListener("click", () => {
|
|
2180
|
+
setState("waiting");
|
|
2181
|
+
opts.onRetry();
|
|
2182
|
+
});
|
|
2183
|
+
const controller = {
|
|
2184
|
+
element: card,
|
|
2185
|
+
setState,
|
|
2186
|
+
state: () => state,
|
|
2187
|
+
start,
|
|
2188
|
+
stop,
|
|
2189
|
+
destroy() {
|
|
2190
|
+
stop();
|
|
2191
|
+
el.innerHTML = "";
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
setState("waiting");
|
|
2195
|
+
if (typeof opts.poll === "function" && opts.autoStart !== false) {
|
|
2196
|
+
start();
|
|
2197
|
+
}
|
|
2198
|
+
return controller;
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
var Steps = {
|
|
2202
|
+
/**
|
|
2203
|
+
* Renders an .mp-steps progress list into el and shows one panel at a time.
|
|
2204
|
+
* opts: { steps: [{ title, panel (selector/element) }], current = 0,
|
|
2205
|
+
* label = 'Progress', onChange(index, step), focus = true }
|
|
2206
|
+
* Returns { go(index), next(), prev(), current(), destroy() }.
|
|
2207
|
+
*/
|
|
2208
|
+
create(el, opts) {
|
|
2209
|
+
opts = opts || {};
|
|
2210
|
+
el = toElement(el);
|
|
2211
|
+
const steps = (opts.steps || []).map((s) => typeof s === "string" ? { title: s } : s);
|
|
2212
|
+
let current = -1;
|
|
2213
|
+
function paint() {
|
|
2214
|
+
el.innerHTML = '<ol class="mp-steps" aria-label="' + escapeHtml(opts.label || "Progress") + '">' + steps.map((s, i) => {
|
|
2215
|
+
const st = i < current ? "complete" : i === current ? "current" : "upcoming";
|
|
2216
|
+
return '<li class="mp-step is-' + st + '"' + (st === "current" ? ' aria-current="step"' : "") + '><span class="mp-step-marker" aria-hidden="true">' + (st === "complete" ? "✓" : i + 1) + '</span><span class="mp-step-title">' + escapeHtml(s.title) + "</span>" + (st === "complete" ? '<span class="mp-sr-only"> (completed)</span>' : "") + "</li>";
|
|
2217
|
+
}).join("") + "</ol>";
|
|
2218
|
+
}
|
|
2219
|
+
function go(index, initial) {
|
|
2220
|
+
if (index < 0 || index >= steps.length || index === current) {
|
|
2221
|
+
return current;
|
|
2222
|
+
}
|
|
2223
|
+
current = index;
|
|
2224
|
+
paint();
|
|
2225
|
+
steps.forEach((s, i) => {
|
|
2226
|
+
const panel = toElement(s.panel);
|
|
2227
|
+
if (panel) {
|
|
2228
|
+
panel.hidden = i !== index;
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
if (!initial) {
|
|
2232
|
+
const panel = toElement(steps[index].panel);
|
|
2233
|
+
if (panel && opts.focus !== false) {
|
|
2234
|
+
focusElement(panel.querySelector("h1, h2, h3, h4, h5, h6, [data-mp-focus]") || panel);
|
|
2235
|
+
}
|
|
2236
|
+
announce("Step " + (index + 1) + " of " + steps.length + ": " + steps[index].title);
|
|
2237
|
+
if (typeof opts.onChange === "function") {
|
|
2238
|
+
opts.onChange(index, steps[index]);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
return current;
|
|
2242
|
+
}
|
|
2243
|
+
go(Math.min(Math.max(opts.current || 0, 0), Math.max(steps.length - 1, 0)), true);
|
|
2244
|
+
return {
|
|
2245
|
+
go: (i) => go(i),
|
|
2246
|
+
next: () => go(current + 1),
|
|
2247
|
+
prev: () => go(current - 1),
|
|
2248
|
+
current: () => current,
|
|
2249
|
+
destroy() {
|
|
2250
|
+
el.innerHTML = "";
|
|
2251
|
+
}
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
|
|
2256
|
+
// src/js/index.js
|
|
2257
|
+
var version = "1.3.0";
|
|
2258
|
+
var MpKit = {
|
|
2259
|
+
version,
|
|
2260
|
+
escapeHtml,
|
|
2261
|
+
announce,
|
|
2262
|
+
StatusBadge,
|
|
2263
|
+
EmptyState,
|
|
2264
|
+
Loading,
|
|
2265
|
+
View,
|
|
2266
|
+
Footer,
|
|
2267
|
+
Tabs,
|
|
2268
|
+
Theme,
|
|
2269
|
+
Toast,
|
|
2270
|
+
confirm,
|
|
2271
|
+
Form,
|
|
2272
|
+
CopyButton,
|
|
2273
|
+
copy: copyText,
|
|
2274
|
+
DeviceList,
|
|
2275
|
+
Skeleton,
|
|
2276
|
+
LogViewer,
|
|
2277
|
+
stripAnsi,
|
|
2278
|
+
Pairing,
|
|
2279
|
+
Auth,
|
|
2280
|
+
Steps,
|
|
2281
|
+
ai
|
|
2282
|
+
};
|
|
2283
|
+
installDelegates();
|
|
793
2284
|
|
|
794
|
-
|
|
2285
|
+
// src/js/global.js
|
|
2286
|
+
root.MpKit = MpKit;
|
|
2287
|
+
})();
|