From ef19155d7b845f6c4ef751e464ad773bb4f2573a Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 27 Sep 2022 17:48:18 -0400 Subject: [PATCH] Support SVG templates (#165) --- .changeset/rare-zebras-check.md | 5 ++ examples/clock/app.js | 86 +++++++++++++++++++++++++++++++++ examples/clock/index.html | 33 +++++++++++++ examples/clock/styles.css | 20 ++++++++ src/each.js | 10 ++-- src/element.js | 51 +++++++++++++++++++ src/render.js | 6 +-- src/scope.js | 4 +- 8 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-zebras-check.md create mode 100644 examples/clock/app.js create mode 100644 examples/clock/index.html create mode 100644 examples/clock/styles.css create mode 100644 src/element.js diff --git a/.changeset/rare-zebras-check.md b/.changeset/rare-zebras-check.md new file mode 100644 index 0000000..f949f8d --- /dev/null +++ b/.changeset/rare-zebras-check.md @@ -0,0 +1,5 @@ +--- +"corset": minor +--- + +Allow using SVG templates diff --git a/examples/clock/app.js b/examples/clock/app.js new file mode 100644 index 0000000..f57bb5a --- /dev/null +++ b/examples/clock/app.js @@ -0,0 +1,86 @@ +import sheet, { mount } from '../../src/main.js'; + +const getSecondsSinceMidnight = () => (Date.now() - new Date().setHours(0, 0, 0, 0)) / 1000; +const rotateFixed = (index, length) => `rotate(${(360 * index) / length})`; +const rotateDyn = (rotate, fixed = 1) => `rotate(${(rotate * 360).toFixed(fixed)})`; + +mount(document, class { + time = getSecondsSinceMidnight(); + + bind() { + return sheet` + .lines { + --rotate: ${rotateFixed}; + } + + .dyn { + --rotate: ${rotateDyn}; + } + + .hours { + each-template: select(#hand-tmpl); + each-items: ${new Array(12)}; + --num-of-lines: 12; + --length: 5; + --width: 2; + } + + .subseconds { + each-template: select(#hand-tmpl); + each-items: ${new Array(60)}; + --num-of-lines: 60; + --length: 2; + --width: 1; + } + + .lines line { + --rotation: --rotate(index(), var(--num-of-lines)); + class-toggle: fixed true; + } + + .hand.dyn { + attach-template: select(#hand-tmpl); + } + + .hour { + --rotation: --rotate(${((this.time / 60 / 60) % 12) / 12}); + --length: 50; + --width: 4; + } + + .minute { + --rotation: --rotate(${((this.time / 60) % 60) / 60}); + --length: 70; + --width: 3; + } + + .second { + --rotation: --rotate(${(this.time % 60) / 60}); + --length: 80; + --width: 2; + } + + .subsecond { + --rotation: --rotate(${this.time % 1}); + --length: 85; + --width: 5; + } + + line { + attr: + stroke-width var(--width), + transform var(--rotation); + } + + line.fixed { + attr: + y1 get(var(--length), ${len => len - 95}), + y2 ${-95}; + } + + line:not(.fixed) { + attr: y2 var(--length); + } + `; + } +}); \ No newline at end of file diff --git a/examples/clock/index.html b/examples/clock/index.html new file mode 100644 index 0000000..3ed2196 --- /dev/null +++ b/examples/clock/index.html @@ -0,0 +1,33 @@ + + + + +Clock + + + +
+ + + {/* static */} + + + + {/* dynamic */} + + + + + + +
+ + + + + + + + diff --git a/examples/clock/styles.css b/examples/clock/styles.css new file mode 100644 index 0000000..22c6056 --- /dev/null +++ b/examples/clock/styles.css @@ -0,0 +1,20 @@ +.clock { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + height: 100vh; +} + +.subsecond { + color: silver; +} + +.hour, +.minute { + color: black; +} + +.second { + color: tomato; +} diff --git a/src/each.js b/src/each.js index 9b5f97d..2182619 100644 --- a/src/each.js +++ b/src/each.js @@ -1,5 +1,6 @@ // @ts-check import { addItemToScope } from './scope.js'; +import { createCloneElement } from './element.js'; const eachSymbol = Symbol.for('corset.each'); const itemSymbol = Symbol.for('corset.item'); const indexSymbol = Symbol.for('corset.index'); @@ -18,14 +19,16 @@ export class EachInstance { keyMap = new Map(); /** * @param {HostElement} host - * @param {HTMLTemplateElement} template + * @param {HTMLTemplateElement | SVGElement} template * @param {string} key */ constructor(host, template, key) { /** @type {HostElement} */ this.host = host; - /** @type {HTMLTemplateElement} */ + /** @type {HTMLTemplateElement | SVGElement} */ this.template = template; + /** @type {() => DocumentFragment} */ + this.clone = createCloneElement(template).bind(null, host, template); /** @type {string} */ this.key = key; /** @type {(item: any, index: number) => any} */ @@ -85,9 +88,8 @@ export class EachInstance { * @returns */ render(index, value) { - let doc = this.host.ownerDocument || document; /** @type {EachFragment} */ - let frag = /** @type {EachFragment} */(doc.importNode(this.template.content, true)); + let frag = /** @type {EachFragment} */(this.clone()); frag.nodes = Array.from(frag.childNodes); frag.data = { item: value, index }; this.setData(frag, value, index); diff --git a/src/element.js b/src/element.js new file mode 100644 index 0000000..dd4532d --- /dev/null +++ b/src/element.js @@ -0,0 +1,51 @@ +// @ts-check + +/** + * @typedef {import('./types').HostElement} HostElement + */ + +/** + * @param {HostElement} host + */ +function getDocument(host) { + return host.ownerDocument || host; +} + +/** + * @param {HostElement} host + * @param {HTMLTemplateElement} template + * @returns {DocumentFragment} + */ +export function cloneTemplate(host, template) { + return getDocument(host).importNode(template.content, true); +} + +/** + * @param {HostElement} host + * @param {SVGElement} element + * @returns {DocumentFragment} + */ +export function cloneSVG(host, element) { + let g = element.firstElementChild?.firstElementChild?.cloneNode(true); + let frag = getDocument(host).createDocumentFragment(); + while(g?.firstChild) { + frag.append(g.firstChild); + } + return frag; +} + +/** + * @param {HTMLTemplateElement | SVGElement} template + * @returns {(host: HostElement, a: any) => DocumentFragment} + */ +export function createCloneElement(template) { + return template.nodeName === 'TEMPLATE' ? cloneTemplate : cloneSVG; +} + +/** + * @param {HostElement} host + * @param {HTMLTemplateElement | SVGElement} template + */ +export function cloneElement(host, template) { + return createCloneElement(template)(host, template); +} \ No newline at end of file diff --git a/src/render.js b/src/render.js index db5d056..1d1d07b 100644 --- a/src/render.js +++ b/src/render.js @@ -2,6 +2,7 @@ import { flags } from './property.js'; import { EachInstance } from './each.js'; +import { cloneElement } from './element.js'; import { Mountpoint } from './mount.js'; import { lookup, addItemToScope, removeItemFromScope } from './scope.js'; import { storePropName, storeDataSelector, Store } from './store.js'; @@ -64,7 +65,7 @@ function render(element, bindings, root, changeset) { } if(bflags & flags.custom) { - if(!(element instanceof HTMLElement)) { + if(!((element instanceof HTMLElement) || (element instanceof SVGElement))) { throw new Error('Custom properties cannot be used on non-HTML elements.'); } @@ -129,8 +130,7 @@ function render(element, bindings, root, changeset) { let result = binding.update(changeset); if(Array.isArray(result)) result = result[0]; if(result === undefined) break attach; - let doc = element.ownerDocument || document; - let frag = doc.importNode(result.content, true); + let frag = cloneElement(element, result); element.replaceChildren(frag); invalid = true; changeset.flags |= flags.attach; diff --git a/src/scope.js b/src/scope.js index b3b0265..26c0da8 100644 --- a/src/scope.js +++ b/src/scope.js @@ -21,7 +21,7 @@ export function lookup(element, dataSelector, propName) { /** * - * @param {HTMLElement} element + * @param {HTMLElement | SVGElement} element * @param {symbol} listSym * @param {string} key * @param {symbol} keySym @@ -45,7 +45,7 @@ export function lookup(element, dataSelector, propName) { /** * - * @param {HTMLElement} element + * @param {HTMLElement | SVGElement} element * @param {symbol} listSym * @param {string} key * @param {symbol} keySym