-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathfactory.js
396 lines (373 loc) · 19 KB
/
factory.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
/* The Chocolate Factory (Thanks to DeviCat for the name!)
Documentation: https://rosuav.github.io/choc/
The MIT License (MIT)
Copyright (c) 2022 Chris Angelico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
export function DOM(sel) {
const elems = document.querySelectorAll(sel);
if (elems.length > 1) throw new Error("Expected a single element '" + sel + "' but got " + elems.length);
return elems[0]; //Will return undefined if there are no matching elements.
}
function flatten_children(child, flat) { //Seriously, programmers are psychos
if (!child || child === "") return;
if (Array.isArray(child)) for (let c of child) flatten_children(c, flat);
else flat.push(child);
}
//Append one child or an array of children
function append_child(elem, child) {
if (!child || child === "") return;
if (Array.isArray(child)) {
//TODO maybe: prevent infinite nesting (array inside itself)
for (let c of child) append_child(elem, c);
return;
}
if (typeof child === "string" || typeof child === "number") child = document.createTextNode(child);
if (child instanceof Node) elem.appendChild(child);
else if (child.tag && child.attributes && Array.isArray(child.children))
//It looks like a Lindt template. We can't just bounce to replace_content though as we might be
//inside an array; otherwise, this check could be done in set_content and the two get unified.
throw new Error("Attempted to insert non-Node object into document - did you mean replace_content?",
{cause: {elem, child}});
else throw new Error("Attempted to insert non-Node object into document",
{cause: {elem, child}});
}
export function set_content(elem, children) {
if (arguments.length > 2) console.warn("Extra argument(s) to set_content() - did you intend to pass an array of children?");
if (typeof elem === "string") {
const el = DOM(elem);
if (!el) throw new Error("No element found for set_content: '" + elem + "'");
elem = el;
}
while (elem.lastChild) elem.removeChild(elem.lastChild);
append_child(elem, children);
return elem;
}
const handlers = {};
export function on(event, selector, handler, options) {
if (handlers[event]) return handlers[event].push([selector, handler]);
handlers[event] = [[selector, handler]];
document.addEventListener(event, e => {
//Reimplement bubbling ourselves. Note that the cancelBubble attribute is
//deprecated, but still seems to work (calling e.stopPropagation() will
//set this attribute), so we use it.
const top = e.currentTarget; //Generic in case we later allow this to attach to other than document
let cur = e.target;
while (cur && cur !== top && !e.cancelBubble) {
e.match = cur; //We can't mess with e.currentTarget without synthesizing our own event object. Easier to make a new property.
handlers[event].forEach(([s, h]) => cur.matches(s) && h(e));
cur = cur.parentNode;
}
e.match = null; //Signal that you can't trust the match ref any more
}, options);
return 1;
}
//Apply some patches to <dialog> tags to make them easier to use. Accepts keyword args in a config object:
// apply_fixes({close_selector: ".dialog_cancel,.dialog_close", click_outside: true});
//For older browsers, this adds showModal() and close() methods
//If cfg.close_selector, will hook events from all links/buttons matching it to close the dialog
//If cfg.click_outside, any click outside a dialog will also close it. (May not work on older browsers.)
export function apply_fixes(cfg) {
if (!cfg) cfg = {};
//For browsers with only partial support for the <dialog> tag, add the barest minimum.
//On browsers with full support, there are many advantages to using dialog rather than
//plain old div, but this way, other browsers at least have it pop up and down.
let need_button_fix = false;
document.querySelectorAll("dialog").forEach(dlg => {
if (!dlg.showModal) {
dlg.showModal = function() {this.style.display = "block";}
dlg.close = function(ret) {
if (ret) this.returnValue = ret;
this.style.removeProperty("display");
this.dispatchEvent(new CustomEvent("close", {bubbles: true}));
};
need_button_fix = true;
}
});
//Ideally, I'd like to feature-detect whether form[method=dialog] actually
//works, and do this if it doesn't; we assume that the lack of a showModal
//method implies this being also unsupported.
if (need_button_fix) on("click", 'dialog form[method="dialog"] button', e => {
e.match.closest("dialog").close(e.match.value);
e.preventDefault();
});
if (cfg.click_outside) on("click", "dialog", e => {
//NOTE: Sometimes, clicking on a <select> will give spurious clientX/clientY
//values. Since clicking outside is always going to send the message directly
//to the dialog (not to one of its children), check for that case.
if (e.match !== e.target) return;
if (cfg.click_outside === "formless" && e.match.querySelector("form")) return;
let rect = e.match.getBoundingClientRect();
if (e.clientY < rect.top || e.clientY > rect.top + rect.height
|| e.clientX < rect.left || e.clientX > rect.left + rect.width)
{
e.match.close();
e.preventDefault();
}
});
if (cfg.close_selector) on("click", cfg.close_selector, e => e.match.closest("dialog").close());
if (cfg.methods) {
//Future expansion: {methods: 1} may be different from {methods: 2}
//elem.closest_data("foo") <=> elem.closest("[data-foo]").dataset.foo
//Node.prototype.closest_data = function(attr) {return this.closest("[data-" + attr + "]").dataset[attr];};
Node.prototype.closest_data = function(attr) {
for (let el = this; el; el = el.parentNode) {
const val = el.dataset?.[attr];
//Note that "if (val)" would exclude those with a value of "", which we should retain.
if (typeof val === "string") return val;
}
return null;
};
}
}
export const fix_dialogs = apply_fixes; //Compatibility name from when it mostly just did dialog fixes
//Compatibility hack for those attributes where not ret[attr] <=> ret.setAttribute(attr). Might be made externally mutable? Maybe?
const attr_xlat = {classname: "class", htmlfor: "for"};
const attr_assign = {volume: 1, value: 1, disabled: 1, checked: 1}; //Another weird compat hack, no idea why
//Exported but with no guarantee of forward compatibility, this is (currently) for internal use.
export function _set_attr(elem, attr, val) {
if (attr[0] === '.') elem[attr.slice(1)] = val; //Explicit assignment. Doesn't use xlat, though maybe it should do it in reverse?
else if (attr[0] === '@') { //Explicit set-attribute
attr = attr.slice(1);
elem.setAttribute(attr_xlat[attr.toLowerCase()] || attr, val);
}
//Otherwise pick what we think is most likely to be right. It often won't matter,
//in which case we'll setAttribute by default.
else if (attr.startsWith("on") || attr_assign[attr]) elem[attr] = val; //Events should be created with on(), but can be done this way too.
else elem.setAttribute(attr_xlat[attr.toLowerCase()] || attr, val);
}
export const xmlns_xlat = {svg: "http://www.w3.org/2000/svg"};
let choc = function(tag, attributes, children) {
const parts = tag.split(":");
const tagname = parts.pop(), ns = parts.join(":");
const ret = ns
? document.createElementNS(xmlns_xlat[ns] || ns, tagname) //XML element with namespace eg choc("svg:svg")
: document.createElement(tagname); //HTML element
//If called as choc(tag, children), assume all attributes are defaults
if (typeof attributes === "string" || typeof attributes === "number" || attributes instanceof Array || attributes instanceof Element) {
//But if called as choc(tag, child, child), that was probably an error.
//It's also possible someone tried to call choc(tag, child, attr); in
//that case, the warning will be slightly confusing, but still point to
//the right place.
if (children) console.warn("Extra argument(s) to choc() - did you intend to pass an array of children?");
return set_content(ret, attributes);
}
if (attributes) for (let attr in attributes) _set_attr(ret, attr, attributes[attr]);
if (children) set_content(ret, children);
//Special case: A <select> element's value is valid only if one of its child <option>s
//has that value. Which means that the value can only be set once it has its children.
//So in that very specific case, we reapply the value here at the end.
if (attributes && children && attributes.value && ret.tagName === "SELECT") {
ret.value = attributes.value;
}
if (arguments.length > 3) console.warn("Extra argument(s) to choc() - did you intend to pass an array of children?");
return ret;
}
export function replace_content(target, template) {
if (typeof target === "string") {
target = DOM(target);
if (!target) throw new Error("No element found for replace_content");
}
let was = target ? target._CHOC_template : [];
if (!was) {
//The first time you use replace_template, it functions broadly like set_content.
set_content(target, "");
was = [];
}
if (!Array.isArray(template)) template = [template];
//In recursive calls, we could skip this JSONification. Note that this breaks embedding
//of DOM elements, functions in on* attributes, etc. It's best done externally if needed.
//template = JSON.parse(JSON.stringify(template)); //Pay some overhead to ensure separation
let nodes = 0; //Number of child nodes, including the contents of subarrays and pseudoelements.
let pristine = true; //False if we make any change, no matter how slight. Err on the side of setting it false unnecessarily.
function build_content(was, now) {
let ofs = 0, limit = Math.abs(was.length - now.length);
let delta = was.length < now.length ? -1 : 1;
now._CHOC_keys = {};
if (was.length !== now.length) pristine = false;
function poke(t, pred) {
//Flag everything that we've used, and refuse to use anything flagged,
//because you can't step in the same river twice.
if (t && !t.key && !t.river && pred(t)) {t.river = 1; return t;}
}
function search(i, pred) {
//Attempt to find an unkeyed entry that matches the predicate
let t;
if (t = poke(was[i + ofs * delta], pred)) return t;
pristine = false;
//Search for a match in the direction of the array length change
let prevofs = ofs;
if (limit) for (++ofs; ofs <= limit; ++ofs)
if (t = poke(was[i + ofs * delta], pred)) return t;
ofs = prevofs; //If we don't find the thing, reset the search for next time.
}
return now.map((t, i) => {
//Strings never get usefully matched. In theory we could search for
//the corresponding text node, to avoid creating and destroying them,
//but in practice, the risk of mismatch means we'd have to do a lot
//of validation, reducing the savings, so we may as well stay simple.
if (!t) {if (was[i]) pristine = false; return "";} //Skip any null entries of any kind
//Strings and numbers get passed straight along to Choc Factory. Elements
//will be kept as-is, so you can move things around by tossing DOM() into
//your template.
if (typeof t === "string" || typeof t === "number") {
if (was[i] !== t) pristine = false;
++nodes;
return t;
}
if (t instanceof Element) {
//DOM elements get passed through untouched, and removed from the template.
//TODO: This previously only set pristine to false when was[i] wasn't null,
//allowing reuse of DOM elements to be more efficient. This however causes
//*replacement* of DOM elements to be ignored, which is a critical failure.
//It would be nice to reinstate the efficiency when reusing, without losing
//the correctness when replacing.
pristine = false;
now[i] = null;
++nodes;
return t;
}
if (Array.isArray(t)) {
//Match an array against another array. Note that an "array with a key"
//is actually represented as a keyed pseudoelement, not an array.
//It is NOT recommended to have a variable number of unkeyed arrays in
//an array, as any array will match any other array, potentially causing
//widespread deletion and recreation. In this situation, give them keys.
//Note that node counts are not affected by arrays themselves, only their contents.
return build_content(search(i, Array.isArray) || [], t);
}
//Assume t is an object.
if (t.key) {
if (now._CHOC_keys[t.key]) console.warn("Duplicate key on element!", t); //No guarantees here.
now._CHOC_keys[t.key] = t;
}
t.position = nodes;
let match = null;
if (t.key) {
const prev = was._CHOC_keys && was._CHOC_keys[t.key];
if (prev && prev.tag === t.tag) match = prev; //Has to match both key and tag.
} else {
//Attempt to find a match based on tag alone.
const tag = t.tag;
match = search(i, x => x.tag === tag);
}
//Four possibilities:
//1) Match found, has tag. Update DOM element and return it.
//2) Match found, no tag. Generate a pseudoelement, reusing as appropriate.
//3) No match, has tag. Generate a DOM element.
//4) No match, no tag. Generate a pseudoelement.
if (match) {
if (!t.tag) return build_content(match.children, t.children);
const elem = target.childNodes[match.position];
if (elem && elem.tagName === match.tag.toUpperCase()) {
//Okay, we have a match. Update attributes, update content, return.
let value = undefined;
for (let old in match.attributes)
//TODO: Translate these through attr_xlat and attr_assign somehow
if (!(old in t.attributes)) {pristine = false; elem.removeAttribute(old);}
for (let att in t.attributes)
if (!(att in match.attributes) || t.attributes[att] !== match.attributes[att]) {
if (elem.tagName === "INPUT" && att === "value") {
//Special-case value to better handle inputs. If you update
//the template to the value it currently has, it's not a
//change; and if you don't update the value at all, it's not
//a change either, to allow easy unmanaged inputs.
if (elem.value === t.attributes[att]) continue;
}
pristine = false;
_set_attr(elem, att, t.attributes[att]);
if (elem.tagName === "SELECT" && att === "value") value = t.attributes.value;
}
//The element will retain its own record of its contents.
++nodes;
replace_content(elem, t.children);
if (typeof value !== "undefined") elem.value = value;
//Set focus back to an element that previously had it.
if (elem === document.activeElement) setTimeout(() => elem.focus(), 0);
return elem;
}
//Else fall through and make a new one. Any sort of DOM manipulation
//that disrupts the position markers could cause a mismatch and thus
//a lot of element creation and destruction, but that's better than
//trying to set attributes onto the wrong type of thing.
}
if (!t.tag) return build_content([], t.children); //Pseudo-element - return the array as-is.
pristine = false;
++nodes;
const elem = replace_content(choc(t.tag, t.attributes), t.children);
if (elem.tagName === "SELECT" && "value" in t.attributes) elem.value = t.attributes.value;
else if (elem.tagName === "SELECT" && ".value" in t.attributes) elem.value = t.attributes[".value"];
return elem;
});
}
if (!target) return build_content(was, template)[0];
target._CHOC_template = template;
//If absolutely nothing has changed - not even text - don't set_content.
//This will be a common case for recursive calls to replace_content, where the
//corresponding section of the overall template hasn't changed.
const content = build_content(was, template);
//If anything's left to be removed, though, it's not pristine. This includes
//any DOM elements directly inserted (which won't have a .river attribute),
//and any Lindt template objects that haven't been probed.
was.forEach(t => {
if (t && typeof t === "object" && !Array.isArray(t) && !t.river) pristine = false;
});
if (pristine) return target;
//At this point, we could just "return set_content(target, content);" but this would unnecessarily
//remove and reinsert DOM elements. This next step is far from perfect but will cope with common
//cases: the first N matching DOM elements will not be removed. TODO: Also check for the _last_ N
//matching elements, which will allow any single block of deletions/insertions.
let keep = 0;
//First, flatten the array. This matching is done without regard to subarray nesting level; during
//the main lindt scan, subarrays are retained as useful clues, but here they are irrelevant.
const flat = [];
flatten_children(content, flat);
while (keep < flat.length && flat[keep] instanceof Node && target.childNodes[keep] === flat[keep]) ++keep;
while (target.childNodes.length > keep) target.removeChild(target.lastChild);
append_child(target, flat.slice(keep));
return target;
}
//TODO: Unify lindt and choc. Maybe have choc call lindt and then render?
let lindt = function(tag, attributes, children) {
if (arguments.length > 3) console.warn("Extra argument(s) to lindt() - did you intend to pass an array of children?");
if (!children && typeof tag === "object") return lindt("", tag, attributes); //Pseudoelement - lindt({key: "..."}, [...])
if (!attributes) attributes = { };
if (typeof attributes === "string" || typeof attributes === "number" || Array.isArray(attributes) || attributes instanceof Element || attributes.tag) {
if (children) console.warn("Extra argument(s) to lindt() - did you intend to pass an array of children?");
children = attributes;
attributes = { };
}
if (!children) children = [];
else if (!Array.isArray(children)) children = [children];
return {tag, attributes, children, key: attributes.key};
};
//Interpret choc.DIV(attr, chld) as choc("DIV", attr, chld)
//This is basically what Python would do as choc.__getattr__()
function autobind(obj, prop) {
if (prop in obj) return obj[prop];
return obj[prop] = obj.bind(null, prop);
}
choc = new Proxy(choc, {get: autobind});
lindt = new Proxy(lindt, {get: autobind});
choc.__version__ = "1.9.3";
//For modules, make the main entry-point easily available.
export default choc;
export {choc, lindt};
//For non-module scripts, allow some globals to be used. Also useful at the console.
//Note that the old name fix_dialogs is used here, but neither name should be necessary
//for modern browsers, and only some fixes are of value.
window.choc = choc; window.set_content = set_content; window.on = on; window.DOM = DOM; window.fix_dialogs = apply_fixes;