WIP WYSIWYG ~3D SVG editor.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Revamp Properties panel implementation

Added EditCommand, broke out panel management into its own class.
The input enhancements now dispatch `change` events when a drag is
finished, in line with what I think is normal-ish browser behavior.
At least for text/text-adjacent inputs, change events are normally
sent when the user finishes editing and "commits" the change.

The property panel now does interactive updates on `input` events, and
commits them to history on `change` events.

+274 -168
+6
input-enhancements.js
··· 52 52 this.startX = 0; 53 53 this.startY = 0; 54 54 55 + const changeEvent = new Event('change', { 56 + bubbles: true, 57 + cancelable: true 58 + }); 59 + this.input.dispatchEvent(changeEvent); 60 + 55 61 this.input.removeEventListener( "pointermove", this._dragMove ); 56 62 this.input.removeEventListener( "pointerup", this._dragEnd ); 57 63 this.input.removeEventListener( "pointercancel", this._dragEnd );
+268 -168
zoodle.js
··· 1 1 class Zoodle { 2 - constructor(sceneElem, overlayElem, uiElem, props) { 2 + constructor(sceneElem, overlayElem, uiElem, panelElem) { 3 3 // TODO: Calculate appropriate zooms and sizes 4 4 let zoom = 10; 5 5 let rotate = { x: -Math.atan(1 / Math.sqrt(2)), y: TAU / 8 }; ··· 35 35 }; 36 36 this.tool = this.tools.translate; 37 37 38 - this.props = props; 38 + this.props = new Properties(panelElem, this); 39 39 40 40 this.presets = { 41 41 solar: new Solar(), ··· 43 43 44 44 this.presets.solar.load(this.scene); 45 45 46 + // TODO: We should capture input when dragging, but this seems to break clicking... 46 47 this.sceneInput = new Zfetch({ 47 48 scene: this.scene, 48 49 click: this.click.bind(this), ··· 62 63 63 64 uiElem.addEventListener("gotpointercapture", _ => uiElem.classList.add("active")); 64 65 uiElem.addEventListener("lostpointercapture", _ => uiElem.classList.remove("active")); 65 - props.addEventListener("input", this.readProperty.bind(this)); 66 66 67 + this.props.updatePanel(); 68 + this.updateHighlights(); 69 + this.updateUI(); 67 70 this.update(); 68 - this.updateProperties(); 69 71 } 70 72 71 73 update() { ··· 147 149 let targets = this.selection.map((target) => { 148 150 return new Zdog.Anchor({ 149 151 addTo: this.ui, 152 + // TODO: Maybe a flag for whether or not to include our own transforms vs just our parents'. 153 + // Cause right now translate widgets are getting rotated when translation is applied before rotation. 150 154 ...this.getWorldTransforms(target), 151 155 }); 152 156 }); ··· 154 158 this.tool.drawWidget(targets); 155 159 } 156 160 157 - // Read a property from the panel into object(s) 158 - readProperty(e) { 159 - let prop = e.target.closest(".prop"); 160 - if (!prop) { 161 - return; 162 - } 163 - let value = this.getProperty(prop); 164 - // TODO: Make a command. 165 - let targets = this.selection; 166 - targets.forEach((target) => { 167 - target[prop.id] = value; 168 - if (target.updatePath) target.updatePath(); 169 - target.updateGraph(); 170 - }); 171 - this.updateHighlights(); 172 - this.updateUI(); 173 - } 174 - 175 - // Update the properties panel to match the selected object(s) 176 - updateProperties() { 177 - // Set properties header. 178 - let header = props.querySelector("h2"); 179 - if (this.selection.length === 0) { 180 - header.textContent = "No objects selected"; 181 - } else if (this.selection.length === 1) { 182 - let path = ""; 183 - let target = this.selection[0]; 184 - while (target.addTo) { 185 - path = `/${target.constructor.type}${path}`; 186 - target = target.addTo; 187 - } 188 - header.textContent = path; 189 - } else { 190 - header.textContent = `${this.selection.length} objects selected`; 191 - } 192 - 193 - // Shortcut: Just hide all entries if nothing's selected. 194 - if (this.selection.length === 0) { 195 - props.classList.add("hidden"); 196 - return; 197 - } 198 - props.classList.remove("hidden"); 199 - 200 - // Set properties from first selected object. 201 - this.setProperties(this.selection[0], true); 202 - 203 - // Condense properties from other selected objects. 204 - for (let i = 1; i < this.selection.length; i++) { 205 - let target = this.selection[i]; 206 - this.setProperties(target, false); 207 - } 208 - } 209 - 210 - // Update the properties panel to match one specific object, optionally merging with the properties that are already there. 211 - setProperties(srcObj, updateValues = true) { 212 - let srcProps = srcObj.constructor.optionKeys; 213 - let destProps = this.props.querySelectorAll(".prop"); 214 - destProps.forEach(prop => { 215 - if (!srcProps.includes(prop.id)) { 216 - prop.classList.add("hidden"); 217 - return; 218 - } 219 - prop.classList.remove("hidden"); 220 - 221 - if (updateValues) { 222 - this.setProperty(srcObj, prop); 223 - return; 224 - } 225 - 226 - let newValue = this.getProperty(prop); 227 - let oldValue = srcObj[prop.id]; 228 - if (newValue != oldValue) { 229 - prop.classList.add("hidden"); 230 - } 231 - }); 232 - } 233 - 234 - // Get the type of a property. 235 - getPropertyType(prop) { 236 - let types = ["vector", "number", "color", "bool"]; 237 - for (let i = 0; i < types.length; i++) { 238 - if (prop.classList.contains(types[i])) { 239 - return types[i]; 240 - } 241 - } 242 - return null; 243 - } 244 - 245 - // Set the value of a property in the properties panel. 246 - setProperty(srcObj, prop) { 247 - let type = this.getPropertyType(prop); 248 - 249 - // check for optional properties that are either false or <value>. 250 - const optional = prop.classList.contains("optional"); 251 - if (optional) { 252 - document.getElementById(`${prop.id}-enabled`).enabled = srcObj[prop.id] !== false; 253 - } 254 - 255 - const input = document.getElementById(`${prop.id}-value`); 256 - 257 - switch (type) { 258 - case "bool": 259 - input.checked = srcObj[prop.id] != false; 260 - break; 261 - case "number": 262 - input.valueAsNumber = srcObj[prop.id]; 263 - break; 264 - case "vector": 265 - let {x, y, z} = srcObj[prop.id]; 266 - document.getElementById(`${prop.id}-x`).value = x; 267 - document.getElementById(`${prop.id}-y`).value = y; 268 - document.getElementById(`${prop.id}-z`).value = z; 269 - break; 270 - case "color": 271 - input.value = this.normalizeColor(srcObj[prop.id]); 272 - break; 273 - } 274 - } 275 - 276 - normalizeColor(color) { 277 - if (/^#[0-9a-fA-F]{6}$/.test(color)) { 278 - return color; 279 - } 280 - 281 - if (/^#[0-9a-fA-F]{3}$/.test(color)) { 282 - return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; 283 - } 284 - 285 - return "#000000"; 286 - } 287 - 288 - // Get the value of a property in the properties panel. 289 - getProperty(prop) { 290 - let type = this.getPropertyType(prop); 291 - 292 - // check for optional properties that are either false or <value>. 293 - const optional = prop.classList.contains("optional"); 294 - if (optional && !document.getElementById(`${prop.id}-enabled`).checked) { 295 - return false; 296 - } 297 - 298 - const input = document.getElementById(`${prop.id}-value`); 299 - 300 - switch (type) { 301 - case "bool": 302 - return input.checked; 303 - case "number": 304 - return input.valueAsNumber; 305 - case "vector": 306 - return { 307 - x: document.getElementById(`${prop.id}-x`).valueAsNumber, 308 - y: document.getElementById(`${prop.id}-y`).valueAsNumber, 309 - z: document.getElementById(`${prop.id}-z`).valueAsNumber, 310 - }; 311 - case "color": 312 - return input.value; 313 - } 314 - return null; 315 - } 316 - 317 161 syncLayers() { 318 162 const scene = this.scene; 319 163 const targets = [this.overlay, this.ui]; ··· 507 351 }); 508 352 this.editor.updateHighlights(); 509 353 this.editor.updateUI(); 510 - this.editor.updateProperties(); 354 + this.editor.props.updatePanel(); 511 355 } 512 356 end(ptr, target, x, y) { 513 357 if (!this.mode) { return; } ··· 614 458 } 615 459 move( ptr, target, x, y ) { 616 460 if (!this.mode) { return; } 617 - console.log("movin"); 618 461 619 462 let displaySize = Math.min( this.editor.scene.width, this.editor.scene.height ); 620 463 x /= displaySize / Math.PI * Zdog.TAU; ··· 626 469 }); 627 470 this.editor.updateHighlights(); 628 471 this.editor.updateUI(); 629 - this.editor.updateProperties(); 472 + this.editor.props.updatePanel(); 630 473 } 631 474 // TODO: Let's stash `delta` somewhere so we don't have to recalculate it in end() 632 475 end( ptr, target, x, y ) { ··· 718 561 refresh() { 719 562 this.editor.updateHighlights(); 720 563 this.editor.updateUI(); 721 - this.editor.updateProperties(); 564 + this.editor.props.updatePanel(); 722 565 } 723 566 } 724 567 ··· 778 621 } 779 622 } 780 623 624 + class EditCommand extends Command { 625 + constructor(editor, target, propId, value, oldValue = null) { 626 + super(editor); 627 + if (!target) { 628 + throw new Error("No target specified for EditCommand"); 629 + } 630 + 631 + if (!Array.isArray(target)) { 632 + target = [target]; 633 + } 634 + this.target = target; 635 + this.propId = propId; 636 + this.value = value; 637 + this.oldValue = oldValue || target.map( (t) => t[propId]); 638 + } 639 + do() { 640 + this.target.forEach( (t) => { 641 + t[this.propId] = this.value; 642 + }); 643 + } 644 + undo() { 645 + this.target.forEach( (t, i) => { 646 + t[this.propId] = this.oldValue[i]; 647 + }); 648 + } 649 + } 650 + 781 651 class History { 782 652 constructor() { 783 653 this.undoStack = []; ··· 812 682 let command = this.redoStack.pop(); 813 683 command.do(); 814 684 this.undoStack.push(command); 685 + } 686 + } 687 + 688 + class Properties { 689 + constructor(panel, editor) { 690 + this.panel = panel; 691 + this.editor = editor; 692 + this.header = this.panel.querySelector("h2"); 693 + this.panel.addEventListener("input", this.handleInput.bind(this)); 694 + this.panel.addEventListener("change", this.handleChange.bind(this)); 695 + this.command = null; 696 + } 697 + 698 + handleInput(event) { 699 + const propElem = event.target.closest(".prop"); 700 + if (!propElem) return; 701 + 702 + console.log("modifying", propElem.id); 703 + 704 + const oldValue = this.readProperty(propElem.id); 705 + if (Array.isArray(oldValue)) { 706 + return; 707 + } 708 + const newValue = this.readPanel(propElem); 709 + // start a new edit command 710 + if (!this.command) { 711 + this.command = new EditCommand(this.editor, this.editor.selection, propElem.id, newValue, oldValue); 712 + } else { 713 + this.command.value = newValue; 714 + } 715 + this.writeProperty(propElem, newValue); 716 + this.editor.updateUI(); 717 + this.editor.updateHighlights(); 718 + } 719 + 720 + handleChange(event) { 721 + // as far as I know, change always fires after input. 722 + this.editor.did(this.command); 723 + this.command = null; 724 + } 725 + 726 + // Synchronize the properties panel with the selected objects 727 + updatePanel() { 728 + this.updateHeader(); 729 + this.updateBody(); 730 + } 731 + 732 + updateHeader() { 733 + const selected = this.editor.selection; 734 + if (selected.length === 0) { 735 + this.header.textContent = "No objects selected"; 736 + } else if (selected.length > 1) { 737 + this.header.textContent = `${selected.length} objects selected`; 738 + } else { 739 + let breadcrumbs = []; 740 + let target = selected[0]; 741 + for (let offset = 0; target.addTo; offset++) { 742 + breadcrumbs.unshift(`<span class="breadcrumb" data-parent-index="${offset}">${target.constructor.type}</span>`); 743 + target = target.addTo; 744 + } 745 + this.header.innerHTML = breadcrumbs.join(" / "); 746 + } 747 + } 748 + 749 + updateBody() { 750 + // Shortcut: Just hide all entries if nothing's selected. 751 + if (this.editor.selection.length === 0) { 752 + this.panel.classList.add("hidden"); 753 + return; 754 + } 755 + this.panel.classList.remove("hidden"); 756 + 757 + // Set properties from first selected object. 758 + this.updatePanelProps(this.editor.selection[0], true); 759 + 760 + // Merge in properties from the rest of the selected objects. 761 + for (let i = 1; i < this.editor.selection.length; i++) { 762 + this.updatePanelProps(this.editor.selection[i]); 763 + } 764 + } 765 + 766 + // Hides properties in the panel that are incompatible with the given object. 767 + // If overwrite is true, it updates the values in the panel from the object. 768 + // If overwrite is false, it hides properties whose values don't match the object. 769 + updatePanelProps(srcObj, overwrite = false) { 770 + let srcProps = srcObj.constructor.optionKeys; 771 + let destProps = this.panel.querySelectorAll(".prop"); 772 + destProps.forEach( (propElem) => { 773 + if (!srcProps.includes(propElem.id)) { 774 + propElem.classList.add("hidden"); 775 + return; 776 + } 777 + propElem.classList.remove("hidden"); 778 + 779 + if (overwrite) { 780 + this.updatePanelProp(srcObj, propElem); 781 + return; 782 + } 783 + 784 + const objValue = srcObj[propElem.id]; 785 + const panelValue = this.readPanel(propElem); 786 + // TODO: This won't work for vector types. 787 + if (panelValue != objValue) { 788 + propElem.classList.add("hidden"); 789 + } 790 + }); 791 + } 792 + 793 + // Writes a single property from the object to the properties panel. 794 + updatePanelProp(srcObj, propElem) { 795 + const type = this.readType(propElem); 796 + 797 + if (this.isOptional(propElem)) { 798 + document.getElementById(`${propElem.id}-enabled`).enabled = srcObj[propElem.id] !== false; 799 + } 800 + 801 + const input = document.getElementById(`${propElem.id}-value`); 802 + 803 + switch (type) { 804 + case "bool": 805 + input.checked = srcObj[propElem.id] != false; 806 + break; 807 + case "number": 808 + input.valueAsNumber = srcObj[propElem.id]; 809 + break; 810 + case "color": 811 + input.value = this.normalizeColor(srcObj[propElem.id]); 812 + break; 813 + case "vector": 814 + let {x, y, z} = srcObj[propElem.id]; 815 + document.getElementById(`${propElem.id}-x`).value = x; 816 + document.getElementById(`${propElem.id}-y`).value = y; 817 + document.getElementById(`${propElem.id}-z`).value = z; 818 + break; 819 + default: 820 + console.warn(`Unknown property type: ${type}`); 821 + } 822 + } 823 + 824 + normalizeColor(color) { 825 + if (/^#[0-9a-fA-F]{6}$/.test(color)) { 826 + return color; 827 + } 828 + 829 + if (/^#[0-9a-fA-F]{3}$/.test(color)) { 830 + return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; 831 + } 832 + 833 + return "#000000"; 834 + } 835 + 836 + // Read a property from the selected objects. 837 + // Returns an array if the property differs across the selection. 838 + readProperty(propId) { 839 + if (!this.editor.selection.length) { 840 + return null; 841 + } 842 + 843 + if (this.editor.selection.length === 1) { 844 + return this.editor.selection[0][propId]; 845 + } 846 + 847 + let values = this.editor.selection.map( (target) => target[propId]); 848 + let allEqual = values.every( (val) => val === values[0]); 849 + if (allEqual) { 850 + return values[0]; 851 + } 852 + return values; 853 + } 854 + 855 + // Write property to the selected objects 856 + // If value is null, it reads the value from the properties panel. 857 + writeProperty(propElem, value = null) { 858 + value = value || this.readPanel(propElem); 859 + let targets = this.editor.selection; 860 + targets.forEach( (target) => { 861 + target[propElem.id] = value; 862 + // TODO: Add additional type information so we can check if we actually need to do this. 863 + if (target.updatePath) target.updatePath(); 864 + }); 865 + } 866 + 867 + // Get the type of a property from the properties panel 868 + readType(propElem) { 869 + let types = ["vector", "number", "color", "bool"]; 870 + for (let i = 0; i < types.length; i++) { 871 + if (propElem.classList.contains(types[i])) { 872 + return types[i]; 873 + } 874 + } 875 + return null; 876 + } 877 + 878 + isOptional(propElem) { 879 + return propElem.classList.contains("optional"); 880 + } 881 + 882 + isEnabled(propElem) { 883 + if (!this.isOptional(propElem)) { 884 + return true; 885 + } 886 + return document.getElementById(propElem.id + "-enabled").checked; 887 + } 888 + 889 + // Read the value of a property from the properties panel 890 + readPanel(propElem) { 891 + const type = this.readType(propElem); 892 + 893 + if (!this.isEnabled(propElem)) { 894 + return false; 895 + } 896 + 897 + const input = document.getElementById(`${propElem.id}-value`); 898 + 899 + switch (type) { 900 + case "bool": 901 + return input.checked; 902 + case "number": 903 + return input.valueAsNumber; 904 + case "color": 905 + return input.value; 906 + case "vector": 907 + return new Zdog.Vector({ 908 + x: document.getElementById(`${propElem.id}-x`).valueAsNumber, 909 + y: document.getElementById(`${propElem.id}-y`).valueAsNumber, 910 + z: document.getElementById(`${propElem.id}-z`).valueAsNumber, 911 + }); 912 + default: 913 + return null; 914 + } 815 915 } 816 916 } 817 917