diff --git a/.gitignore b/.gitignore index 7abc853e..de5b572a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,13 @@ app.json build data dist -jszip-esm.js logs mod node_modules pack package-lock.json -three.js +src/ext/jszip-esm.js +src/ext/quickjs.js +src/ext/three.js tmp web/boot/bundle* diff --git a/src/kiri/core/init.js b/src/kiri/core/init.js index 0ac072b5..ef338077 100644 --- a/src/kiri/core/init.js +++ b/src/kiri/core/init.js @@ -5,6 +5,7 @@ import { api } from './api.js'; import { base } from '../../geo/base.js'; import { local as sdb } from '../../data/local.js'; import { slider } from './slider.js'; +import { keyboard } from './keyboard.js'; import { space } from '../../moto/space.js'; import { LOCAL, SETUP } from './main.js'; import { VIEWS, MODES, SEED } from './consts.js'; @@ -237,296 +238,6 @@ function onButtonClick(ev) { api.event.on('click.button', onButtonClick); -function inputHasFocus() { - let active = DOC.activeElement; - return active && (active.nodeName === "INPUT" || active.nodeName === "TEXTAREA"); -} - -function cca(c) { - return c.charCodeAt(0); -} - -function keyUpHandler(evt) { - if (api.feature.on_key) { - if (api.feature.on_key({up:evt})) return; - } - for (let handler of api.feature.on_key2) { - if (handler({up:evt})) return; - } - switch (evt.keyCode) { - // escape - case 27: - // blur text input focus - DOC.activeElement.blur(); - // dismiss modals - api.modal.hide(); - // deselect widgets - platform.deselect(); - // hide all dialogs - api.dialog.hide(); - // cancel slicing - api.function.cancel(); - // and send an event (used by FDM client) - api.event.emit("key.esc"); - break; - } - return false; -} - -function keyDownHandler(evt) { - if (api.modal.visible()) { - return false; - } - if (api.feature.on_key) { - if (api.feature.on_key({down:evt})) return; - } - for (let handler of api.feature.on_key2) { - if (handler({down:evt})) return; - } - let move = evt.altKey ? 5 : 0, - deg = move ? 0 : -Math.PI / (evt.shiftKey ? 36 : 2); - switch (evt.keyCode) { - case 8: // apple: delete/backspace - case 46: // others: delete - if (inputHasFocus()) return false; - platform.delete(selection.meshes()); - evt.preventDefault(); - break; - case 37: // left arrow - if (inputHasFocus()) return false; - if (deg) selection.rotate(0, 0, -deg); - if (move > 0) selection.move(-move, 0, 0); - evt.preventDefault(); - break; - case 39: // right arrow - if (inputHasFocus()) return false; - if (deg) selection.rotate(0, 0, deg); - if (move > 0) selection.move(move, 0, 0); - evt.preventDefault(); - break; - case 38: // up arrow - if (inputHasFocus()) return false; - if (evt.metaKey) { - const { hi } = slider.getRange(); - return api.show.layer(hi + 1); - } - if (deg) selection.rotate(deg, 0, 0); - if (move > 0) selection.move(0, move, 0); - evt.preventDefault(); - break; - case 40: // down arrow - if (inputHasFocus()) return false; - if (evt.metaKey) { - const { hi } = slider.getRange(); - return api.show.layer(hi - 1); - } - if (deg) selection.rotate(-deg, 0, 0); - if (move > 0) selection.move(0, -move, 0); - evt.preventDefault(); - break; - case 65: // 'a' for select all - if (evt.metaKey || evt.ctrlKey) { - if (inputHasFocus()) return false; - evt.preventDefault(); - platform.deselect(); - platform.select_all(); - } - break; - case 83: // 's' for save workspace - if (evt.ctrlKey) { - evt.preventDefault(); - api.conf.save(); - console.log("settings saved"); - } else - if (evt.metaKey) { - evt.preventDefault(); - api.space.save(); - set_ctrl.sync.put(); - } - break; - case 76: // 'l' for restore workspace - if (evt.metaKey) { - evt.preventDefault(); - api.space.restore(); - } - break; - } -} - -function keyHandler(evt) { - let handled = true; - if (api.modal.visible() || inputHasFocus()) { - return false; - } - if (api.feature.on_key) { - if (api.feature.on_key({key:evt})) return; - } - for (let handler of api.feature.on_key2) { - if (handler({key:evt})) return; - } - if (evt.ctrlKey) { - switch (evt.key) { - case 'g': return api.group.merge(); - case 'u': return api.group.split(); - } - } - switch (evt.charCode) { - case cca('`'): api.show.slices(0); break; - case cca('0'): { - const { max } = slider.getRange(); - api.show.slices(max); - break; - } - case cca('1'): { - const { max } = slider.getRange(); - api.show.slices(max/10); - break; - } - case cca('2'): { - const { max } = slider.getRange(); - api.show.slices(max*2/10); - break; - } - case cca('3'): { - const { max } = slider.getRange(); - api.show.slices(max*3/10); - break; - } - case cca('4'): { - const { max } = slider.getRange(); - api.show.slices(max*4/10); - break; - } - case cca('5'): { - const { max } = slider.getRange(); - api.show.slices(max*5/10); - break; - } - case cca('6'): { - const { max } = slider.getRange(); - api.show.slices(max*6/10); - break; - } - case cca('7'): { - const { max } = slider.getRange(); - api.show.slices(max*7/10); - break; - } - case cca('8'): { - const { max } = slider.getRange(); - api.show.slices(max*8/10); - break; - } - case cca('9'): { - const { max } = slider.getRange(); - api.show.slices(max*9/10); - break; - } - case cca('?'): - api.help.show(); - break; - case cca('Z'): // reset stored state - uc.confirm('clear all settings and preferences?').then(yes => { - if (yes) { - sdb.clear(); - WIN.location.reload(); - } - }); - break; - case cca('C'): // refresh catalog - catalog.refresh(); - break; - case cca('i'): // file import - api.event.import(); - break; - case cca('S'): // slice - case cca('s'): // slice - api.function.slice(); - break; - case cca('P'): // prepare - case cca('p'): // prepare - if (api.mode.get() !== 'SLA') { - // hidden in SLA mode - api.function.prepare(); - } - break; - case cca('X'): // export - case cca('x'): // export - api.function.export(); - break; - case cca('g'): // CAM animate - api.function.animate(); - break; - case cca('O'): // manual rotation - rotateInputSelection(); - break; - case cca('r'): // recent files - api.modal.show('files'); - break; - case cca('q'): // preferences - api.modal.show('prefs'); - break; - case cca('l'): // device - settingsLoad(); - break; - case cca('e'): // device - api.show.devices(); - break; - case cca('o'): // tools - api.show.tools(); - break; - case cca('c'): // local devices - api.show.local(); - break; - case cca('v'): // toggle single slice view mode - if (api.view.get() === VIEWS.ARRANGE) { - api.space.set_focus(selection.widgets()); - } - const { lo, hi } = slider.getRange(); - if (hi === lo) { - slider.setRange(0, hi); - } else { - slider.setRange(hi, hi); - } - api.show.slices(); - break; - case cca('d'): // duplicate object - duplicateSelection(); - break; - case cca('m'): // mirror object - mirrorSelection(); - break; - case cca('a'): - if (api.view.get() === VIEWS.ARRANGE) { - // auto arrange items on platform - platform.layout(); - if (!api.conf.get().controller.spaceRandoX) { - api.space.set_focus(selection.widgets()); - } - } else { - // go to arrange view - api.view.set(VIEWS.ARRANGE); - } - break; - default: - api.event.emit('keypress', evt); - handled = false; - break; - } - if (handled) { - evt.preventDefault(); - evt.stopPropagation(); - } - return false; -} - -function duplicateSelection() { - selection.duplicate(); -} - -function mirrorSelection() { - selection.mirror(); -} function keys(o) { let key, list = []; @@ -1154,7 +865,8 @@ function init_one() { tracker: tracker, mobile: space.info.mob, onLayerChange: (hi, lo) => { - api.show.layer(hi, lo); + // Visualization is handled by the code that calls setRange() + // Don't call api.show.layer here as it creates circular dependency }, onStackUpdate: (lo, hi) => { STACKS.setRange(lo, hi); @@ -1167,6 +879,27 @@ function init_one() { // expose slider to API api.slider = slider; + // initialize keyboard controller + keyboard.init({ + api, + platform, + selection, + slider, + space, + catalog, + sdb, + uc, + setCtrl: set_ctrl, + VIEWS, + DOC, + WIN, + rotateInputSelection, + settingsLoad + }); + + // expose keyboard to API + api.keyboard = keyboard; + // add mobile class to body if needed if (space.info.mob) { DOC.body.classList.add('mobile'); @@ -1225,9 +958,6 @@ function init_one() { }; space.event.addHandlers(self, [ - 'keyup', keyUpHandler, - 'keydown', keyDownHandler, - 'keypress', keyHandler, 'dragover', dragOverHandler, 'dragleave', dragLeave, 'drop', dropHandler @@ -1704,8 +1434,8 @@ function init_two() { $('mesh-export-obj').onclick = () => { objectsExport('obj') }; $('mesh-merge').onclick = selection.merge; $('mesh-split').onclick = selection.isolateBodies; - $('context-duplicate').onclick = duplicateSelection; - $('context-mirror').onclick = mirrorSelection; + $('context-duplicate').onclick = selection.duplicate; + $('context-mirror').onclick = selection.mirror; $('context-layflat').onclick = () => { api.event.emit("tool.mesh.lay-flat") }; $('context-lefty').onclick = () => { api.event.emit("tool.mesh.lefty") }; $('context-setfocus').onclick = () => { diff --git a/src/kiri/core/keyboard.js b/src/kiri/core/keyboard.js new file mode 100644 index 00000000..87d54a9a --- /dev/null +++ b/src/kiri/core/keyboard.js @@ -0,0 +1,398 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +/** + * Keyboard Controller (Singleton) + * Manages keyboard event handling and shortcuts + */ + +class KeyboardControl { + #api; + #platform; + #selection; + #slider; + #space; + #catalog; + #sdb; + #uc; + #setCtrl; + #VIEWS; + #DOC; + #WIN; + #rotateInputSelection; + #settingsLoad; + #initialized = false; + + /** + * Initialize the keyboard controller with dependencies + * @param {Object} config + * @param {Object} config.api - API object + * @param {Object} config.platform - Platform object + * @param {Object} config.selection - Selection object + * @param {Object} config.slider - Slider controller + * @param {Object} config.space - Space object + * @param {Object} config.catalog - Catalog object + * @param {Object} config.sdb - Local storage + * @param {Object} config.uc - User confirmation utility + * @param {Object} config.setCtrl - Settings controller + * @param {Object} config.VIEWS - View constants + * @param {Document} config.DOC - Document object + * @param {Window} config.WIN - Window object + * @param {Function} config.rotateInputSelection - Rotate input callback + * @param {Function} config.settingsLoad - Settings load callback + */ + init({ api, platform, selection, slider, space, catalog, sdb, uc, setCtrl, VIEWS, DOC, WIN, rotateInputSelection, settingsLoad }) { + if (this.#initialized) { + throw new Error('Keyboard already initialized'); + } + + this.#api = api; + this.#platform = platform; + this.#selection = selection; + this.#slider = slider; + this.#space = space; + this.#catalog = catalog; + this.#sdb = sdb; + this.#uc = uc; + this.#setCtrl = setCtrl; + this.#VIEWS = VIEWS; + this.#DOC = DOC; + this.#WIN = WIN; + this.#rotateInputSelection = rotateInputSelection; + this.#settingsLoad = settingsLoad; + + this.#bindEvents(); + this.#initialized = true; + } + + /** + * Check if an input element has focus + * @returns {boolean} + */ + inputHasFocus() { + let active = this.#DOC.activeElement; + return active && (active.nodeName === "INPUT" || active.nodeName === "TEXTAREA"); + } + + /** + * Get character code at position 0 + * @param {string} c - Character + * @returns {number} + */ + #cca(c) { + return c.charCodeAt(0); + } + + #bindEvents() { + this.#space.event.addHandlers(self, [ + 'keyup', this.#handleKeyUp.bind(this), + 'keydown', this.#handleKeyDown.bind(this), + 'keypress', this.#handleKeyPress.bind(this) + ]); + } + + #handleKeyUp(evt) { + // Allow feature hooks to intercept + if (this.#api.feature.on_key) { + if (this.#api.feature.on_key({up:evt})) return; + } + for (let handler of this.#api.feature.on_key2) { + if (handler({up:evt})) return; + } + + switch (evt.keyCode) { + // escape + case 27: + // blur text input focus + this.#DOC.activeElement.blur(); + // dismiss modals + this.#api.modal.hide(); + // deselect widgets + this.#platform.deselect(); + // hide all dialogs + this.#api.dialog.hide(); + // cancel slicing + this.#api.function.cancel(); + // trigger escape handlers (used by FDM mode for support editing) + this.#onEscape(); + break; + } + return false; + } + + #handleKeyDown(evt) { + if (this.#api.modal.visible()) { + return false; + } + + // Allow feature hooks to intercept + if (this.#api.feature.on_key) { + if (this.#api.feature.on_key({down:evt})) return; + } + for (let handler of this.#api.feature.on_key2) { + if (handler({down:evt})) return; + } + + let move = evt.altKey ? 5 : 0, + deg = move ? 0 : -Math.PI / (evt.shiftKey ? 36 : 2); + + switch (evt.keyCode) { + case 8: // apple: delete/backspace + case 46: // others: delete + if (this.inputHasFocus()) return false; + this.#platform.delete(this.#selection.meshes()); + evt.preventDefault(); + break; + case 37: // left arrow + if (this.inputHasFocus()) return false; + if (deg) this.#selection.rotate(0, 0, -deg); + if (move > 0) this.#selection.move(-move, 0, 0); + evt.preventDefault(); + break; + case 39: // right arrow + if (this.inputHasFocus()) return false; + if (deg) this.#selection.rotate(0, 0, deg); + if (move > 0) this.#selection.move(move, 0, 0); + evt.preventDefault(); + break; + case 38: // up arrow + if (this.inputHasFocus()) return false; + if (evt.metaKey) { + const { hi } = this.#slider.getRange(); + return this.#api.show.layer(hi + 1); + } + if (deg) this.#selection.rotate(deg, 0, 0); + if (move > 0) this.#selection.move(0, move, 0); + evt.preventDefault(); + break; + case 40: // down arrow + if (this.inputHasFocus()) return false; + if (evt.metaKey) { + const { hi } = this.#slider.getRange(); + return this.#api.show.layer(hi - 1); + } + if (deg) this.#selection.rotate(-deg, 0, 0); + if (move > 0) this.#selection.move(0, -move, 0); + evt.preventDefault(); + break; + case 65: // 'a' for select all + if (evt.metaKey || evt.ctrlKey) { + if (this.inputHasFocus()) return false; + evt.preventDefault(); + this.#platform.deselect(); + this.#platform.select_all(); + } + break; + case 83: // 's' for save workspace + if (evt.ctrlKey) { + evt.preventDefault(); + this.#api.conf.save(); + console.log("settings saved"); + } else + if (evt.metaKey) { + evt.preventDefault(); + this.#api.space.save(); + this.#setCtrl.sync.put(); + } + break; + case 76: // 'l' for restore workspace + if (evt.metaKey) { + evt.preventDefault(); + this.#api.space.restore(); + } + break; + } + } + + #handleKeyPress(evt) { + let handled = true; + if (this.#api.modal.visible() || this.inputHasFocus()) { + return false; + } + + // Allow feature hooks to intercept + if (this.#api.feature.on_key) { + if (this.#api.feature.on_key({key:evt})) return; + } + for (let handler of this.#api.feature.on_key2) { + if (handler({key:evt})) return; + } + + // Handle Ctrl key combinations + if (evt.ctrlKey) { + switch (evt.key) { + case 'g': return this.#api.group.merge(); + case 'u': return this.#api.group.split(); + } + } + + switch (evt.charCode) { + case this.#cca('`'): this.#api.show.slices(0); break; + case this.#cca('0'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max); + break; + } + case this.#cca('1'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max/10); + break; + } + case this.#cca('2'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*2/10); + break; + } + case this.#cca('3'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*3/10); + break; + } + case this.#cca('4'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*4/10); + break; + } + case this.#cca('5'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*5/10); + break; + } + case this.#cca('6'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*6/10); + break; + } + case this.#cca('7'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*7/10); + break; + } + case this.#cca('8'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*8/10); + break; + } + case this.#cca('9'): { + const { max } = this.#slider.getRange(); + this.#api.show.slices(max*9/10); + break; + } + case this.#cca('?'): + this.#api.help.show(); + break; + case this.#cca('Z'): // reset stored state + this.#uc.confirm('clear all settings and preferences?').then(yes => { + if (yes) { + this.#sdb.clear(); + this.#WIN.location.reload(); + } + }); + break; + case this.#cca('C'): // refresh catalog + this.#catalog.refresh(); + break; + case this.#cca('i'): // file import + this.#api.event.import(); + break; + case this.#cca('S'): // slice + case this.#cca('s'): // slice + this.#api.function.slice(); + break; + case this.#cca('P'): // prepare + case this.#cca('p'): // prepare + if (this.#api.mode.get() !== 'SLA') { + // hidden in SLA mode + this.#api.function.prepare(); + } + break; + case this.#cca('X'): // export + case this.#cca('x'): // export + this.#api.function.export(); + break; + case this.#cca('g'): // CAM animate + this.#api.function.animate(); + break; + case this.#cca('O'): // manual rotation + this.#rotateInputSelection(); + break; + case this.#cca('r'): // recent files + this.#api.modal.show('files'); + break; + case this.#cca('q'): // preferences + this.#api.modal.show('prefs'); + break; + case this.#cca('l'): // device + this.#settingsLoad(); + break; + case this.#cca('e'): // device + this.#api.show.devices(); + break; + case this.#cca('o'): // tools + this.#api.show.tools(); + break; + case this.#cca('c'): // local devices + this.#api.show.local(); + break; + case this.#cca('v'): // toggle single slice view mode + if (this.#api.view.get() === this.#VIEWS.ARRANGE) { + this.#api.space.set_focus(this.#selection.widgets()); + } + const { lo, hi } = this.#slider.getRange(); + if (hi === lo) { + this.#slider.setRange(0, hi); + } else { + this.#slider.setRange(hi, hi); + } + this.#api.show.slices(); + break; + case this.#cca('d'): // duplicate object + this.#selection.duplicate(); + break; + case this.#cca('m'): // mirror object + this.#selection.mirror(); + break; + case this.#cca('a'): + if (this.#api.view.get() === this.#VIEWS.ARRANGE) { + // auto arrange items on platform + this.#platform.layout(); + if (!this.#api.conf.get().controller.spaceRandoX) { + this.#api.space.set_focus(this.#selection.widgets()); + } + } else { + // go to arrange view + this.#api.view.set(this.#VIEWS.ARRANGE); + } + break; + default: + this.#onUnhandledKey(evt); + handled = false; + break; + } + if (handled) { + evt.preventDefault(); + evt.stopPropagation(); + } + return false; + } + + /** + * Called when escape key is pressed + * Replaces api.event.emit('key.esc') + */ + #onEscape() { + // Notify listeners (currently used by FDM mode for support editing) + this.#api.event.emit("key.esc"); + } + + /** + * Called when an unhandled key is pressed + * Replaces api.event.emit('keypress', evt) + */ + #onUnhandledKey(evt) { + // Notify listeners for custom key handling + this.#api.event.emit('keypress', evt); + } +} + +// Export singleton instance +export const keyboard = new KeyboardControl(); diff --git a/src/kiri/core/main.js b/src/kiri/core/main.js index d222094e..0ce2af7e 100644 --- a/src/kiri/core/main.js +++ b/src/kiri/core/main.js @@ -327,7 +327,7 @@ function setVisibleLayer(h, l) { l = l >= 0 ? l : lo; const newHi = bound(h, 0, max); const newLo = bound(l, 0, newHi); - api.slider.setRange(newLo, newHi); + api.slider.setRange(newLo, newHi); // Notify callbacks (STACKS, scene updates) api.slider.showLabels(); showSlices(); } diff --git a/src/kiri/core/slider.js b/src/kiri/core/slider.js index d7db613f..f588340b 100644 --- a/src/kiri/core/slider.js +++ b/src/kiri/core/slider.js @@ -68,13 +68,16 @@ class SliderControl { * Set layer range (bounds are enforced) * @param {number} lo - Lower layer * @param {number} hi - Upper layer + * @param {boolean} notify - Whether to trigger callbacks (default: true) */ - setRange(lo, hi) { + setRange(lo, hi, notify = true) { this.#layerLo = Math.max(0, Math.min(hi, lo)); this.#layerHi = Math.max(this.#layerLo, Math.min(this.#layerMax, hi)); this.#updateVisuals(); this.showLabels(); - this.#notifyChange(); + if (notify) { + this.#notifyChange(); + } } /**