diff --git a/app.js b/app.js index 4d0a2831..5de08939 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,7 @@ const mods = {}; const load = []; const api = {}; +let lastTouchTime = {}; let forceUseCache = false; let serviceWorker = true; let crossOrigin = false; @@ -213,9 +214,19 @@ function init(mod) { } } - // create alt artifacts with module extensions + // synthesize new main when applicable + createArtifacts(); +} + +// create alt artifacts with module extensions +function createArtifacts() { if (dryrun || !isElectron) { - logger.log('creating artifacts', Object.keys(append)); + if (debug) { + setTimeout(createArtifacts, 1000); + } + if (Object.keys(lastTouchTime).length === 0) { + logger.log('creating artifacts', Object.keys(append)); + } for (let [ key, val ] of Object.entries(append)) { // append mains let src = `${dir}/src/main/${key}.js`; @@ -223,6 +234,14 @@ function init(mod) { logger.log('missing', src); continue; } + let ltt = fs.statSync(src).mtimeMs; + if (lastTouchTime[src] === ltt) { + continue; + } else if (debug) { + logger.log('changed', src); + } + lastTouchTime[src] = ltt; + // console.log({ src, ltt }); fs.mkdirSync(`${dir}/alt/main`, { recursive: true }); let body = fs.readFileSync(src); fs.writeFileSync(`${dir}/alt/main/${key}.js`, body + val); @@ -239,7 +258,7 @@ function init(mod) { } else { logger.log('skipping artifacts'); } -}; +} // either add module assets to path or require(init.js) function loadModule(mod, dir) { diff --git a/src/kiri/app/init/input.js b/src/kiri/app/init/input.js index fb131ae6..bb8151aa 100644 --- a/src/kiri/app/init/input.js +++ b/src/kiri/app/init/input.js @@ -171,6 +171,7 @@ export function init_input() { event.on('resize', onResize); // configure moto.space + space.view.setFitPadding({ perspective: 0.8 }); space.sky.showGrid(false); space.sky.setColor(controller.dark ? 0 : 0xffffff); space.setAntiAlias(controller.antiAlias); diff --git a/src/kiri/app/platform.js b/src/kiri/app/platform.js index ca375317..3b387298 100644 --- a/src/kiri/app/platform.js +++ b/src/kiri/app/platform.js @@ -172,14 +172,14 @@ function update_size(updateDark = true) { space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.05); space.sky.set({ color: 0, ambient: { intensity: 0.6 } }); - document.body.classList.add('dark'); + document.documentElement.classList.add('dark'); } else { space.platform.set({ light: 0.08 }); space.platform.setFont({rulerColor:'#333333'}); space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.2); space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } }); - document.body.classList.remove('dark'); + document.documentElement.classList.remove('dark'); } space.platform.setSize(); } diff --git a/src/kiri/app/preferences.js b/src/kiri/app/preferences.js index 8facac53..3b32da5a 100644 --- a/src/kiri/app/preferences.js +++ b/src/kiri/app/preferences.js @@ -83,7 +83,7 @@ function booleanSave() { control.assembly = ui.assembly.checked; control.autoLayout = ui.autoLayout.checked; control.autoSave = ui.autoSave.checked; - control.dark = ui.dark.checked; + control.dark = api.sdb['kiri-dark'] = ui.dark.checked; control.devel = ui.devel.checked; control.drawer = ui.drawer.checked; control.exportOcto = ui.exportOcto.checked; diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 36f32589..3db3af20 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -387,7 +387,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { /** * when moving between contour endpoints, check if we can * instead route around the bounding area of the contour - * whih we call the coastline. + * which we call the coastline. */ function coastlineMove(point) { let from = toWidgetCoords(printPoint); @@ -395,6 +395,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (!coastline || from.distTo2D(to) < 0.01) { return false; } + let minz = Math.min(from.z, to.z); let start = { dist: 1, poly: 0, pt: from }; let end = { dist: 1, poly: 1, pt: to }; for (let poly of coastline) { @@ -445,7 +446,9 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } } for (let i=sp, d=0; d < dist; i += dir, d++) { - layerPush(toWorkCoords(points[i % pl]), 1, 0, tool); + let cp = points[i % pl].clone(); + cp.z = Math.max(minz, cp.z); + layerPush(toWorkCoords(cp), 1, 0, tool); } return true; } diff --git a/src/main/mesh.js b/src/main/mesh.js index f0cbf138..6f08309d 100644 --- a/src/main/mesh.js +++ b/src/main/mesh.js @@ -17,10 +17,12 @@ import { edges as meshEdges } from '../mesh/edges.js'; import { open as dataOpen } from '../data/index.js'; import { load as fileLoad } from '../load/file.js'; import { THREE } from '../ext/three.js'; +import { createDocumentManager } from '../mesh/document.js'; const version = '1.5.7'; const call = broker.send; -const dbindex = [ "admin", "space" ]; +const dbindex = [ "admin", "documents", "versions" ]; +const DOC_META_KEY = '__doc'; const { Quaternion } = THREE; @@ -28,10 +30,67 @@ function log() { return api.log.emit(...arguments); } +function boot_status(message = 'loading...') { + const curtain = $('curtain'); + if (!curtain) return; + curtain.textContent = String(message || 'loading...'); +} + +function boot_start(message = 'loading...') { + $('app')?.classList?.add('booting'); + boot_status(message); + $d('curtain', 'flex'); +} + +function boot_done() { + $('app')?.classList?.remove('booting'); + $d('curtain', 'none'); +} + +function get_doc_meta(meta = metaCache) { + if (!meta || typeof meta !== 'object') return {}; + return meta[DOC_META_KEY] || {}; +} + +function capture_camera_state() { + return { + place: space.view.save(), + focus: space.view.getFocus() + }; +} + +function apply_camera_state(camera) { + if (!camera) return; + if (camera.place) { + space.view.load(camera.place); + } + if (camera.focus) { + space.view.setFocus(camera.focus); + } +} + +function save_camera_to_document() { + const dmeta = get_doc_meta(metaCache); + metaCache[DOC_META_KEY] = { + ...dmeta, + camera: capture_camera_state() + }; + store_meta(); +} + +let cameraSaveTimer = null; +function schedule_camera_save(delay = 120) { + clearTimeout(cameraSaveTimer); + cameraSaveTimer = setTimeout(() => { + cameraSaveTimer = null; + save_camera_to_document(); + }, delay); +} + // set below. called once the DOM readyState = complete // this is the main() entrypoint called after all dependents load function init() { - let stores = dataOpen('mesh', { stores: dbindex, version: 4 }).init(), + let stores = dataOpen('mesh', { stores: dbindex, version: 5 }).init(), dark = false, ortho = false, zoomrev = true, @@ -39,11 +98,21 @@ function init() { platform = space.platform, db = api.db = { admin: stores.promise('admin'), - space: stores.promise('space') + documents: stores.promise('documents'), + versions: stores.promise('versions') }; + const docman = api.document = createDocumentManager({ + admin: db.admin, + documents: db.documents, + versions: db.versions, + maxRevisions: 200 + }); + db.space = docman.spaceStore; + // initialize the API (to avoid circular dependencies) api.init(); + boot_start('initializing mesh:tool'); // mark init time and use count db.admin.put("init", Date.now()); @@ -70,26 +139,23 @@ function init() { colorX: 0xff7777, colorY: 0x7777ff }, }); platform.onMove(() => { - // save last location and focus - db.admin.put('camera', { - place: space.view.save(), - focus: space.view.getFocus() - }); + // save camera per-document + save_camera_to_document(); }, 100); space.view.setZoom(zoomrev, zoomspd); + // trigger ui building + call.ui_build(); + + // trigger space event binding + call.space_init({ space: space, platform }); + // reload stored space when worker is ready motoClient.on('ready', restore_space); // start worker motoClient.start('../lib/mesh/work.js?' + version); - // trigger space event binding - call.space_init({ space: space, platform }); - - // trigger ui building - call.ui_build(); - // hide url params let wlp = window.location.pathname; let mio = wlp.indexOf('/mesh/'); @@ -102,32 +168,27 @@ function init() { self.electron = navigator.userAgent.includes('Electron'); } -// restore space layout and view from previous session -async function restore_space() { - const db_admin = api.db.admin; +function clear_workspace() { + api.selection.clear(); + for (let sk of api.sketch.list().slice()) { + sk.remove(); + } + for (let grp of api.group.list().slice()) { + grp.remove(); + } +} + +async function restore_workspace_from_state(cached = {}, mcache = {}) { const db_space = api.db.space; - // let mcache = {}; - await db_admin.get("camera") - .then(saved => { - if (saved) { - space.view.load(saved.place); - space.view.setFocus(saved.focus); - } - }); - const mcache = await db_admin.get("meta") || {}; let count = 0; - await db_space.iterate({ map: true }).then(cached => { + await Promise.resolve(cached).then(cached => { const keys = []; const claimed = []; for (let [id, data] of Object.entries(cached)) { - // console.log({ id, data }); keys.push(id); if (count++ === 0) { log(`restoring workspace`); } - // restore object based on type - // group arrays load models they contain - // sketches are loaded by type since they're not grouped if (Array.isArray(data)) { claimed.push(id); let models = data @@ -135,11 +196,11 @@ async function restore_space() { claimed.push(id); return { id, md: cached[id] } }) - .filter(r => r.md) // filter cache misses - .map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id])) + .filter(r => r.md) + .map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id])); if (models.length) { log(`restored ${models.length} model(s)`); - api.group.new(models, id).applyMeta(mcache[id]) + api.group.new(models, id).applyMeta(mcache[id]); } else { log(`removed empty group ${id}`); db_space.remove(id); @@ -155,22 +216,17 @@ async function restore_space() { if (keys.length) { log(`removing ${keys.length} unclaimed meshes`); } - // clear out meshes left in the space db along with their meta-data for (let id of keys) { db_space.remove(id); delete mcache[id]; } - // restore global cache only after objects are restored - // otherwise their setup will corrupt the cache for other restores metaCache = mcache; - store_meta(); + api.document.setMeta(metaCache); }).then(() => { - // restore preferences after models are restored return api.prefs.load().then(() => { let { map } = api.prefs; let { space, mode } = map; api.grid(space.grid); - // restore selected state let selist = space.select || []; let smodel = api.model.list().filter(m => selist.contains(m.id)); let sgroup = api.group.list().filter(m => selist.contains(m.id)); @@ -179,14 +235,38 @@ async function restore_space() { let tgroup = api.group.list().filter(m => tolist.contains(m.id)); let sklist = api.sketch.list().filter(s => selist.contains(s.id)); api.selection.set([...smodel, ...sgroup, ...sklist], [...tmodel, ...tgroup]); - // restore edit mode api.mode[mode](); - // restore dark mode - set_darkmode(map.space.dark); + set_darkmode(); }); - }).finally(() => { + }); +} + +// restore space layout and view from previous session +async function restore_space() { + const db_admin = api.db.admin; + const db_space = api.db.space; + const docman = api.document; + boot_status('loading document'); + const currentDoc = await docman.restoreOrCreate(); + const mcache = docman.getMeta() || {}; + const oldCamera = await db_admin.get("camera"); + const docCamera = get_doc_meta(mcache).camera || oldCamera || null; + boot_status('restoring workspace'); + const cached = await db_space.iterate({ map: true }) || {}; + docman.pause(); + try { + await restore_workspace_from_state(cached, mcache); + } finally { + docman.resume(); + } + boot_status('restoring view'); + apply_camera_state(docCamera); + space.update(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + boot_status('finalizing'); + Promise.resolve().finally(() => { // hide loading curtain - $d('curtain','none'); + boot_done(); // restore handles visibility handles.setEnabled(api.prefs.map.space.bounds ?? false); // restore script if was showing @@ -196,15 +276,103 @@ async function restore_space() { if (api.prefs.map.info.welcome !== false) { api.welcome(version); } + api.file.set_doc_name(currentDoc?.name || 'Untitled'); broker.publish("app_ready"); }); } +async function document_new(opt = {}) { + const docman = api.document; + await docman.flush(); + await docman.commit('document.autosave', 'document.autosave'); + docman.pause(); + try { + clear_workspace(); + space.view.home(); + metaCache = { + [DOC_META_KEY]: { + camera: capture_camera_state() + } + }; + await docman.create(opt.name || 'Untitled'); + docman.setMeta(metaCache); + } finally { + docman.resume(); + } + await docman.commit('document.new', 'document.new'); + api.file.set_doc_name(docman.current?.name || 'Untitled'); +} + +async function document_open(opt = {}) { + const docman = api.document; + const id = String(opt?.id || ''); + if (!id) return; + await docman.flush(); + await docman.open(id, { autosave: opt.autosave !== false }); + const cached = docman.getSpace() || {}; + const mcache = docman.getMeta() || {}; + docman.pause(); + try { + clear_workspace(); + await restore_workspace_from_state(cached, mcache); + } finally { + docman.resume(); + } + apply_camera_state(get_doc_meta(mcache).camera); + api.file.set_doc_name(docman.current?.name || 'Untitled'); +} + // add space event bindings function space_init(data) { let platcolor = 0x00ff00; let { space, platform } = data; let { selection } = api; + + function selection_or_visible_entities() { + const selected = api.selection.list(true); + if (selected?.length) return selected; + return [ + ...api.group.list().filter(g => g.visible()), + ...api.sketch.list().filter(s => s.visible()) + ]; + } + + function fit_visible() { + const entities = selection_or_visible_entities(); + const objects = entities.map(e => e?.object).filter(o => o); + return space.view.fit(undefined, { + padding: 1, + visibleOnly: true, + objects: objects.length ? objects : undefined + }); + } + + function focus_visible() { + const entities = selection_or_visible_entities(); + if (entities.length) { + return api.focus(entities); + } + return api.focus([ + ...api.group.list(), + ...api.sketch.list() + ]); + } + + function norm_code(evt) { + if (evt?.code) return evt.code; + const key = evt?.key; + if (!key) return ''; + if (key === ' ') return 'Space'; + if (key === 'Spacebar') return 'Space'; + if (key === 'Escape') return 'Escape'; + if (key.length === 1) { + const up = key.toUpperCase(); + if (up >= 'A' && up <= 'Z') return `Key${up}`; + if (up >= '0' && up <= '9') return `Digit${up}`; + } + return key; + } + // add file drop handler space.event.addHandlers(self, [ 'drop', (evt) => { @@ -222,6 +390,10 @@ function space_init(data) { 'dragleave', evt => { platform.set({ opacity: 0, color: platcolor }); }, + // camera interactions (orbit/pan/dolly) are not guaranteed to trigger platform.onMove + 'wheel', () => schedule_camera_save(), + 'mouseup', () => schedule_camera_save(), + 'touchend', () => schedule_camera_save(), 'keypress', evt => { if (api.modal.showing) { return; @@ -229,7 +401,8 @@ function space_init(data) { if (evt.key === '?') { return api.welcome(version); } - let { shiftKey, metaKey, ctrlKey, code, target } = evt; + let { shiftKey, metaKey, ctrlKey, target } = evt; + let code = norm_code(evt); if (target.nodeName === 'TEXTAREA') { api.script.changed(); return; @@ -250,7 +423,11 @@ function space_init(data) { case 'KeyB': return selection.boundsBox({toggle:true}); case 'KeyC': - return selection.centerXY().focus(); + if (shiftKey) { + return selection.floor(); + } else { + return selection.centerXY().focus(); + } case 'KeyD': return shiftKey && api.tool.duplicate(); case 'KeyE': @@ -259,14 +436,14 @@ function space_init(data) { return api.sketch.extrude(); } return; - case 'KeyF': - return shiftKey ? selection.focus() : selection.floor().focus(); case 'KeyG': return shiftKey ? (api.mode.is([ api.modes.sketch ]) ? api.sketch.arrange.group() : api.tool.regroup()) : api.grid(); case 'KeyH': - return shiftKey ? selection.hide() : space.view.home(); + if (shiftKey) return selection.hide(); + schedule_camera_save(180); + return space.view.home(); case 'KeyI': return api.file.import(); case 'KeyL': @@ -283,7 +460,9 @@ function space_init(data) { if (!api.mode.is([ api.modes.object ])) return; return shiftKey ? selection.visible({toggle:true}) : meshSplit.start(); case 'KeyT': - return shiftKey ? api.tool.triangulate() : space.view.top(); + if (shiftKey) return api.tool.triangulate(); + schedule_camera_save(180); + return space.view.top(); case 'KeyU': return shiftKey && api.tool.union(); case 'KeyV': @@ -295,7 +474,9 @@ function space_init(data) { } }, 'keydown', evt => { - let { shiftKey, metaKey, ctrlKey, code, target } = evt; + let { shiftKey, metaKey, ctrlKey, target } = evt; + let code = norm_code(evt); + const key = evt?.key; if (target.nodeName === 'TEXTAREA') { if (code === 'Tab') { estop(evt); @@ -320,14 +501,36 @@ function space_init(data) { delete keyOnce[code]; return once(evt); } - let rv = (Math.PI / 12); if (api.modal.showing) { if (code === 'Escape') { api.modal.cancel(); } return; } - let rot, floor = api.prefs.map.space.floor !== false; + const isFit = code === 'KeyF' || + key === 'f' || + key === 'F'; + if (isFit && !(metaKey || ctrlKey)) { + estop(evt); + const rv = shiftKey ? focus_visible() : fit_visible(); + schedule_camera_save(220); + return rv; + } + const isSpace = code === 'Space' || + code === 'Spacebar' || + key === ' ' || + key === 'Spacebar'; + if (isSpace) { + if (selection.clear()) { + meshEdges.clear(); + meshSplit.end(); + } + estop(evt); + return; + } + let rv = (Math.PI / 12); + let rot; + let floor = api.prefs.map.space.floor !== false; switch (code) { case 'KeyA': estop(evt); @@ -353,7 +556,9 @@ function space_init(data) { if (metaKey || ctrlKey) { return shiftKey ? api.history.redo() : api.history.undo(); } else { - return space.view.reset(); + space.view.reset(); + schedule_camera_save(220); + return; } case 'Escape': if (selection.clear()) { @@ -664,7 +869,7 @@ function key_once_cancel(code) { } function store_meta() { - api.db.admin.put("meta", metaCache); + api.document?.setMeta?.(metaCache); } function update_meta(id, data) { @@ -694,28 +899,19 @@ function object_destroy(id) { function set_darkmode(dark) { let { prefs, model } = api; let { sky, platform } = space; - prefs.map.space.dark = dark; - if (dark) { - materials.wireframe.color.set(0xaaaaaa); - materials.wireline.color.set(0xaaaaaa); - $('app').classList.add('dark'); - } else { - materials.wireframe.color.set(0,0,0); - materials.wireline.color.set(0,0,0); - $('app').classList.remove('dark'); - } + dark = true; + prefs.map.space.dark = true; + materials.wireframe.color.set(0xaaaaaa); + materials.wireline.color.set(0xaaaaaa); sky.set({ - color: dark ? 0 : 0xffffff, - ambient: { intensity: dark ? 0.55 : 1.1 } + color: 0, + ambient: { intensity: 0.55 } }); platform.set({ - light: dark ? 0.08 : 0.08, - grid: dark ? { + light: 0.08, + grid: { colorMajor: 0x666666, colorMinor: 0x333333, - } : { - colorMajor: 0xcccccc, - colorMinor: 0xeeeeee, }, }); api.updateFog(); @@ -741,11 +937,8 @@ function set_normals_length(length) { function set_normals_color(color) { let { prefs, model } = api; let { map } = prefs; - if (map.space.dark) { - map.normals.color_dark = color || 0; - } else { - map.normals.color_lite = color || 0; - } + map.normals.color_dark = color || 0; + map.normals.color_lite = color || 0; prefs.save(); // Update existing normals for (let m of model.list()) { @@ -809,7 +1002,9 @@ broker.listeners({ set_surface_radius, set_wireframe_opacity, set_wireframe_fog, - set_snap_value + set_snap_value, + document_new, + document_open }); init(); @@ -837,5 +1032,7 @@ export { set_surface_radius, set_wireframe_opacity, set_wireframe_fog, - set_snap_value + set_snap_value, + document_new, + document_open }; diff --git a/src/main/void.js b/src/main/void.js index c552c2ee..b8b74a63 100644 --- a/src/main/void.js +++ b/src/main/void.js @@ -109,10 +109,11 @@ function setupLeftPanelResize(db) { event.stopPropagation(); }; - window.addEventListener('resize', () => { - const curr = left.getBoundingClientRect().width; - applyWidth(curr); - }); + const onResize = () => { + applyWidth(left.getBoundingClientRect().width); + } + + window.addEventListener('resize', onResize); } // Main initialization function @@ -289,26 +290,20 @@ async function init() { toolbar.updateDocumentTitle(); tree.render(); - // TEST: Add example overlay elements - // These demonstrate the 2D overlay tracking 3D points - if (true) { // Set to false to disable test overlays - const { THREE } = window; + const { THREE } = window; - // Show overlay - overlay.show(); + // Show overlay + overlay.show(); - // Add test points at origin and along axes - overlay.add('origin-point', 'point', { - pos3d: new THREE.Vector3(0, 0, 0), - radius: 4.8, - color: 'rgba(140, 140, 140, 0.45)', - stroke: '#5a9fd4', - strokeWidth: 2 - }); - api.origin.syncOverlayPoint(); - - console.log({ test_overlays_added: 1 }); - } + // Add origin + overlay.add('origin-point', 'point', { + pos3d: new THREE.Vector3(0, 0, 0), + radius: 4.8, + color: 'rgba(140, 140, 140, 0.45)', + stroke: '#5a9fd4', + strokeWidth: 2 + }); + api.origin.syncOverlayPoint(); // Hide loading curtain const curtain = $('curtain'); @@ -320,6 +315,9 @@ async function init() { }, 300); } + // update canvas based on left panel size + space.event.onResize(); + console.log({ void_form_ready: true }); } diff --git a/src/mesh/api.js b/src/mesh/api.js index de17b7ac..d885e27b 100644 --- a/src/mesh/api.js +++ b/src/mesh/api.js @@ -972,6 +972,93 @@ let add = { }; let file = { + set_doc_name(name = 'Untitled') { + const label = String(name || 'Untitled').trim() || 'Untitled'; + document.title = `${label} | Mesh:Tool`; + const el = $('top-doc-name'); + if (el) { + el.textContent = label; + el.title = `click to rename (${label})`; + } + }, + + async new() { + await api.document?.flush?.(); + await call.document_new({ name: 'Untitled' }); + api.file.set_doc_name('Untitled'); + }, + + async open() { + const docs = await (api.document?.list?.() || Promise.resolve([])); + const rows = docs.map(doc => h.div({ class: "doc-open-row", onclick: async function() { + api.modal.hide(); + await call.document_open({ id: doc.id }); + api.file.set_doc_name(doc.name || 'Untitled'); + } }, [ + h.div({ class: "doc-open-name", _: `${doc.name || 'Untitled'}` }), + h.button({ _: "rename", onclick(evt) { + evt?.stopPropagation?.(); + api.modal.hide(); + setTimeout(() => api.file.rename(doc), 0); + } }), + h.button({ class: "doc-open-del", _: "×", title: "delete", onclick: async (evt) => { + evt?.stopPropagation?.(); + await api.file.delete(doc); + } }) + ])); + api.modal.dialog({ + title: "open document", + body: [ h.div({ class: "doc-open-list" }, [ + ...rows, + rows.length ? undefined : h.div({ class: "doc-open-empty", _: "no documents" }), + h.hr(), + h.button({ class: "doc-open-new", _: "new", onclick() { + api.modal.hide(); + api.file.new(); + } }) + ].filter(v => v)) ] + }); + }, + + async rename(doc = api.document?.current) { + const current = doc || api.document?.current; + if (!current?.id) return; + if (api.modal?.showing) { + api.modal.hide(); + await Promise.resolve(); + } + + let onclick = onkeydown = (ev) => { + if (!tempedit || (ev.code && ev.code !== 'Enter')) { + return; + } + api.document.rename(current.id, tempedit.value).then(rec => { + if (rec?.id && api.document?.current?.id === rec.id) { + api.file.set_doc_name(rec.name || 'Untitled'); + } + }).finally(() => api.modal.hide()); + }; + + let { tempedit } = api.modal.show(`rename document`, h.div({ class: "rename"}, [ + h.input({ id: "tempedit", value: current.name || 'Untitled', onkeydown }), + h.button({ _: 'ok', onclick }) + ])); + tempedit.setSelectionRange(0,1000); + tempedit.focus(); + }, + + async delete(doc = api.document?.current) { + const current = doc || api.document?.current; + if (!current?.id) return; + const result = await api.document?.delete?.(current.id); + if (result?.switched && result?.current?.id) { + await call.document_open({ id: result.current.id, autosave: false }); + api.file.set_doc_name(result.current.name || 'Untitled'); + } + api.modal.hide(); + api.file.open(); + }, + import() { // binding created in mesh.build $('import').click(); @@ -1348,7 +1435,7 @@ const mode = { $(`mode-${key}`).classList.remove('selected'); } $(`mode-${mode}`).classList.add('selected'); - $('mode-label').innerText = mode; + $('top-mode-label').innerText = mode; api.mode.check(); meshEdges?.end(); if (mode === 'sketch') { @@ -1572,10 +1659,30 @@ const api = { history: { undo() { - history.undo(); + if (api.document?.undo) { + api.document.undo().then(changed => { + if (changed) { + call.document_open({ id: api.document.current?.id, autosave: false }); + } else { + history.undo(); + } + }); + } else { + history.undo(); + } }, redo() { - history.redo(); + if (api.document?.redo) { + api.document.redo().then(changed => { + if (changed) { + call.document_open({ id: api.document.current?.id, autosave: false }); + } else { + history.redo(); + } + }); + } else { + history.redo(); + } } }, @@ -1701,6 +1808,8 @@ const api = { sketch, + space: motoSpace, + tool, isDebug: self.debug === true diff --git a/src/mesh/build.js b/src/mesh/build.js index d8da4482..fc88e7e9 100644 --- a/src/mesh/build.js +++ b/src/mesh/build.js @@ -19,7 +19,10 @@ let deg = Math.PI / 180; let und = undefined; broker.listeners({ - ui_build + ui_build, + app_ready() { + api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled'); + } }); let spin_timer; @@ -240,14 +243,10 @@ api.welcome = function(version = "unknown") { api.settings = function() { const { prefs } = api; const { surface, normals, space, sketch, wireframe } = prefs.map; - const { dark } = space; + const dark = true; const set1 = div([ - label('dark mode'), - input({ type: "checkbox", - onchange: ev => call.set_darkmode(ev.target.checked), - [ dark ? 'checked' : 'unchecked' ] : 1 - }), + label({ class: "header", _: 'auto'}), label('auto floor'), input({ type: "checkbox", onchange: ev => prefs.save( space.floor = !space.floor ), @@ -391,6 +390,8 @@ function ui_build() { // top left drop menus bind($('top-left'), [ + div({ _: 'Mesh:Tool', class: "title" }), + div({ class: "menubar-separator" }), div({ class: "menu" }, [ div('File'), div({ class: "menu-items" }, [ @@ -398,12 +399,17 @@ function ui_build() { id: "import", type: "file", class: ["hide"], multiple: true, accept:".stl,.obj", onchange(evt) { broker.send.load_files(evt.target.files) } }), + menu_item('New', file.new), + menu_item('Open', file.open), + hr(), menu_item('Import', file.import, 'I'), menu_item('Export', file.export, 'X'), hr(), menu_item('Slicer', api.kirimoto), menu_item('Script', api.script.toggle), hr(), + menu_item('Preferences', api.settings, 'Q'), + hr(), menu_item('Close', () => window.close() || api.kirimoto()), ]) ]), @@ -467,7 +473,6 @@ function ui_build() { menu_item('Face', mode.face, '5', 'mode-face'), menu_item('Edge', mode.edge, '6', 'mode-edge'), ]), - div({ id: "mode-label" }) ]), div({ class: "menu sketch-on" }, [ div('Items'), @@ -510,7 +515,7 @@ function ui_build() { div({ class: "menu sketch-off" }, [ div('Faces'), div({ class: "menu-items" }, [ - menu_item('Flip Normals', tool.invert, ['bi-shift','I']), + menu_item('Flip Normals', tool.invert, ['bi-shift','N']), menu_item('Triangulate', tool.triangulate, ['bi-shift','T']), menu_item('To Sketch', tool.toSketch), hr(), @@ -535,6 +540,7 @@ function ui_build() { ]) ]), div({ class: "menu" }, [ + // div({ class: "fas fa-question" }), div('Help'), div({ class: "menu-items" }, [ menu_item('About', () => { api.welcome(version) }), @@ -546,15 +552,15 @@ function ui_build() { menu_item('Versions', api.version), ]) ]), + div({ class: "menubar-separator" }), + div({ id: "top-mode-label" }), ]); // add help buttons bind($('top-right'), [ - div({ id: "top-settings", onclick: api.settings }, [ - div({ class: "fas fa-gear" }), - div('Settings') - ]), + div({ id: "top-doc-name", onclick: () => api.file.rename(), _: 'Untitled' }), ]); + api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled'); // modal dialog and page blocker bind($('modal_page'), [ @@ -600,32 +606,55 @@ function ui_build() { return div({ onclick: fn, class: "tool" }, [ bicon(icon), div([ label(help) ]) ]); } + function toolbar_separator() { + return div({ class: "toolbar-separator" }); + } + // bind sketch chiclets bind(sketchtools, div([ tool_item('bi-plus', 'New Sketch', add.sketch), + toolbar_separator(), tool_item('bi-circle', 'Add Circle', api.add.circle), + toolbar_separator(), tool_item('bi-square', 'Add Rectangle', api.add.rectangle), + toolbar_separator(), tool_item('bi-symmetry-vertical', 'Flip Horizontal', api.sketch.arrange.fliph), + toolbar_separator(), tool_item('bi-symmetry-horizontal', 'Flip Vertical', api.sketch.arrange.flipv), + toolbar_separator(), tool_item('bi-arrow-clockwise', 'Rotate', api.sketch.arrange.rotate), + toolbar_separator(), tool_item('bi-union', 'Union', sketch.boolean.union), + toolbar_separator(), tool_item('bi-intersect', 'Intersect', sketch.boolean.intersect), + toolbar_separator(), tool_item('bi-exclude', 'Difference', sketch.boolean.difference), + toolbar_separator(), tool_item('bi-pip', 'Nest', sketch.boolean.nest), + toolbar_separator(), tool_item('bi-layers', 'Flatten', sketch.boolean.flatten), + toolbar_separator(), tool_item('bi-cookie', 'Even Odd', sketch.boolean.evenodd), + toolbar_separator(), tool_item('bi-arrow-bar-up', 'Extrude', () => sketch.extrude()), ])); // bind object chiclets bind(objecttools, div([ tool_item('bi-pencil', 'New Sketch', add.sketch), + toolbar_separator(), tool_item('bi-box', 'New Cube', add.cube), + toolbar_separator(), tool_item('bi-database', 'New Cylinder', add.cylinder), + toolbar_separator(), tool_item('bi-gear', 'New Gear', add.gear), + toolbar_separator(), tool_item('bi-union', 'Union', tool.union), + toolbar_separator(), tool_item('bi-subtract', 'Subtract', tool.subtract), + toolbar_separator(), tool_item('bi-intersect', 'Intersect', tool.intersect), + toolbar_separator(), tool_item('bi-exclude', 'Difference', tool.difference), ])); diff --git a/src/mesh/document.js b/src/mesh/document.js new file mode 100644 index 00000000..6772f6dd --- /dev/null +++ b/src/mesh/document.js @@ -0,0 +1,316 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function uid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID().replace(/-/g, '').slice(0, 12); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +function clone(data) { + if (data === undefined || data === null) return data; + if (typeof structuredClone === 'function') { + return structuredClone(data); + } + return JSON.parse(JSON.stringify(data)); +} + +function title(name = '') { + const clean = String(name || '').trim(); + return clean || 'Untitled'; +} + +function revKey(docId, revId) { + return `${docId}:${revId}`; +} + +export function createDocumentManager({ admin, documents, versions, maxRevisions = 200 }) { + const state = { + admin, + documents, + versions, + maxRevisions, + doc: null, + space: {}, + meta: {}, + pauseDepth: 0, + commitTimer: null, + commitDelay: 350 + }; + + function paused() { + return state.pauseDepth > 0; + } + + async function saveDoc() { + if (!state.doc) return; + await state.documents.put(state.doc.id, clone(state.doc)); + await state.admin.put('current_document_id', state.doc.id); + } + + async function loadSnapshot(doc, revId = null) { + if (!doc) { + state.space = {}; + state.meta = {}; + return null; + } + const rid = revId || doc.cursor_rev || doc.head_rev || null; + if (!rid) { + state.space = {}; + state.meta = {}; + return null; + } + const rec = await state.versions.get(revKey(doc.id, rid)); + const snap = rec?.snapshot || {}; + state.space = clone(snap.space || {}); + state.meta = clone(snap.meta || {}); + return rec || null; + } + + async function maybePruneRevisions() { + const order = Array.isArray(state.doc?.rev_order) ? state.doc.rev_order : []; + const over = order.length - state.maxRevisions; + if (over <= 0) return; + const purge = order.splice(0, over); + for (const rid of purge) { + await state.versions.remove(revKey(state.doc.id, rid)); + } + if (state.doc.cursor_rev && !order.includes(state.doc.cursor_rev)) { + state.doc.cursor_rev = order[0] || null; + } + if (state.doc.head_rev && !order.includes(state.doc.head_rev)) { + state.doc.head_rev = order[order.length - 1] || null; + } + } + + async function commit(op_type = 'autosave', label = 'autosave') { + if (!state.doc || paused()) return null; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order.slice() : []; + const cursor = state.doc.cursor_rev || null; + const cursorIndex = cursor ? order.indexOf(cursor) : -1; + if (cursorIndex >= 0 && cursorIndex < order.length - 1) { + const remove = order.slice(cursorIndex + 1); + for (const rid of remove) { + await state.versions.remove(revKey(state.doc.id, rid)); + } + order.length = cursorIndex + 1; + } + const parent = order.length ? order[order.length - 1] : null; + const rid = uid(); + await state.versions.put(revKey(state.doc.id, rid), { + doc_id: state.doc.id, + rev_id: rid, + parent_rev: parent, + created_at: Date.now(), + op_type, + label, + snapshot: { + space: clone(state.space), + meta: clone(state.meta) + } + }); + order.push(rid); + state.doc.rev_order = order; + state.doc.head_rev = rid; + state.doc.cursor_rev = rid; + state.doc.updated_at = Date.now(); + await maybePruneRevisions(); + await saveDoc(); + return rid; + } + + function scheduleCommit(op_type = 'autosave', label = 'autosave') { + if (!state.doc || paused()) return; + clearTimeout(state.commitTimer); + state.commitTimer = setTimeout(() => { + state.commitTimer = null; + commit(op_type, label).catch(error => console.trace(error)); + }, state.commitDelay); + } + + async function createDoc(name = 'Untitled') { + const id = uid(); + const now = Date.now(); + state.doc = { + id, + name: title(name), + created_at: now, + updated_at: now, + head_rev: null, + cursor_rev: null, + rev_order: [] + }; + state.space = {}; + state.meta = {}; + await commit('document.create', 'document.create'); + return clone(state.doc); + } + + async function restoreOrCreate() { + let docId = await state.admin.get('current_document_id'); + let doc = docId ? await state.documents.get(docId) : null; + if (!doc) { + const listed = await state.documents.iterate({ map: true }) || {}; + const docs = Object.values(listed); + if (docs.length) { + docs.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + doc = docs[0]; + } + } + if (!doc) { + return createDoc('Untitled'); + } + state.doc = clone(doc); + await loadSnapshot(state.doc); + await saveDoc(); + return clone(state.doc); + } + + async function open(docId, { autosave = true } = {}) { + if (autosave) { + await flush(); + await commit('document.autosave', 'document.autosave'); + } + const next = await state.documents.get(docId); + if (!next) throw new Error(`document ${docId} missing`); + state.doc = clone(next); + await loadSnapshot(state.doc); + await saveDoc(); + return clone(state.doc); + } + + async function rename(docId, name) { + const doc = await state.documents.get(docId); + if (!doc) return null; + doc.name = title(name); + doc.updated_at = Date.now(); + await state.documents.put(doc.id, doc); + if (state.doc?.id === doc.id) { + state.doc = clone(doc); + await state.admin.put('current_document_id', doc.id); + } + return clone(doc); + } + + async function list() { + const map = await state.documents.iterate({ map: true }) || {}; + return Object.values(map) + .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + } + + async function remove(docId) { + const id = String(docId || ''); + if (!id) return { deleted: false, switched: false, current: clone(state.doc) }; + await flush(); + const doc = await state.documents.get(id); + if (!doc) return { deleted: false, switched: false, current: clone(state.doc) }; + const revs = Array.isArray(doc.rev_order) ? doc.rev_order : []; + for (const rid of revs) { + await state.versions.remove(revKey(id, rid)); + } + await state.documents.remove(id); + if (state.doc?.id !== id) { + return { deleted: true, switched: false, current: clone(state.doc) }; + } + const map = await state.documents.iterate({ map: true }) || {}; + const docs = Object.values(map).sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + if (!docs.length) { + await createDoc('Untitled'); + return { deleted: true, switched: true, current: clone(state.doc) }; + } + state.doc = clone(docs[0]); + await loadSnapshot(state.doc); + await saveDoc(); + return { deleted: true, switched: true, current: clone(state.doc) }; + } + + async function undo() { + await flush(); + if (!state.doc) return false; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : []; + if (order.length < 2) return false; + const idx = order.indexOf(state.doc.cursor_rev); + if (idx <= 0) return false; + state.doc.cursor_rev = order[idx - 1]; + state.doc.updated_at = Date.now(); + await loadSnapshot(state.doc, state.doc.cursor_rev); + await saveDoc(); + return true; + } + + async function redo() { + await flush(); + if (!state.doc) return false; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : []; + const idx = order.indexOf(state.doc.cursor_rev); + if (idx < 0 || idx >= order.length - 1) return false; + state.doc.cursor_rev = order[idx + 1]; + state.doc.updated_at = Date.now(); + await loadSnapshot(state.doc, state.doc.cursor_rev); + await saveDoc(); + return true; + } + + async function flush() { + if (state.commitTimer) { + clearTimeout(state.commitTimer); + state.commitTimer = null; + await commit('autosave.flush', 'autosave.flush'); + } + } + + const spaceStore = { + async put(key, value) { + state.space[String(key)] = clone(value); + scheduleCommit('space.put', `space.put:${key}`); + return value; + }, + async remove(key) { + delete state.space[String(key)]; + scheduleCommit('space.remove', `space.remove:${key}`); + return true; + }, + async get(key) { + return clone(state.space[String(key)]); + }, + async iterate(opt = {}) { + if (opt?.map) { + return clone(state.space); + } + return Object.entries(state.space || {}); + } + }; + + return { + spaceStore, + pause() { + state.pauseDepth++; + }, + resume() { + state.pauseDepth = Math.max(0, state.pauseDepth - 1); + }, + setMeta(meta = {}) { + state.meta = clone(meta || {}); + scheduleCommit('meta.set', 'meta.set'); + }, + getMeta() { + return clone(state.meta || {}); + }, + getSpace() { + return clone(state.space || {}); + }, + get current() { + return clone(state.doc); + }, + restoreOrCreate, + create: createDoc, + open, + rename, + delete: remove, + list, + commit, + flush, + undo, + redo + }; +} diff --git a/src/moto/orbit.js b/src/moto/orbit.js index 689e4b70..99bfa367 100644 --- a/src/moto/orbit.js +++ b/src/moto/orbit.js @@ -235,15 +235,33 @@ class Orbit extends EventDispatcher { this.setPosition = function(set) { thetaSet = firstValue([set.left, set.theta, thetaSet]); phiSet = firstValue([set.up, set.phi, phiSet]); - if (set.panX !== undefined) this.target.x = set.panX; - if (set.panY !== undefined) this.target.y = set.panY; - if (set.panZ !== undefined) this.target.z = set.panZ; + let target = this.target; + let position = this.object.position; + if (set.posX !== undefined) position.x = set.posX; + if (set.posY !== undefined) position.y = set.posY; + if (set.posZ !== undefined) position.z = set.posZ; + if (set.panX !== undefined) target.x = set.panX; + if (set.panY !== undefined) target.y = set.panY; + if (set.panZ !== undefined) target.z = set.panZ; if (set.scale !== undefined) scale = set.scale; + else scale = 1; + this.update(); }; - this.getPosition = function(scaled) { + this.getPosition = function({ scaled } = { scaled: false }) { let t = this.target, - pos = { left:theta, up:phi, panX:t.x, panY:t.y, panZ:t.z, scale:scaled ? scaleSave : 1 }; + p = this.object.position, + pos = { + left: theta, + up: phi, + panX: t.x, + panY: t.y, + panZ: t.z, + posX: p.x, + posY: p.y, + posZ: p.z, + scale: scaled ? scaleSave : undefined + }; return pos; }; diff --git a/src/moto/space.js b/src/moto/space.js index d193d062..0fcda855 100644 --- a/src/moto/space.js +++ b/src/moto/space.js @@ -1560,26 +1560,49 @@ let Space = { front: (then) => { runPreset(0, PI2, then) }, right: (then) => { runPreset(PI2, PI2, then) }, left: (then) => { runPreset(-PI2, PI2, then) }, - reset: () => { viewControl.reset(); requestRefresh() }, - load: (cam) => { viewControl.setPosition(cam); requestRefresh() }, - save: () => { return viewControl.getPosition(true) }, - panTo: (x,y,z,l,u,t,upVec) => { tweenCamPan(x,y,z,l,u,t,upVec) }, - setZoom: (r,v) => { viewControl.setZoom(r,v) }, - fit: (then, opts = {}) => { + reset: () => { + viewControl.reset(); + requestRefresh() + }, + load: (cam) => { + viewControl.setPosition(cam); + requestRefresh(); + }, + save: () => { + return viewControl.getPosition(true); + }, + panTo: (x,y,z,l,u,t,upVec) => { + tweenCamPan(x,y,z,l,u,t,upVec); + }, + setZoom: (r,v) => { + viewControl.setZoom(r,v); + }, + fit: (then, opts = {}) => { // Calculate bounding box of all objects in the workspace const box = new THREE.Box3(); let hasObjects = false; const visibleOnly = opts.visibleOnly !== undefined ? !!opts.visibleOnly : fitVisibleOnly; + const targetObjects = Array.isArray(opts.objects) ? opts.objects.filter(Boolean) : null; - // Recursively expand box for all visible objects with geometry - WORLD.traverse(obj => { - if (!obj.geometry) return; - if (visibleOnly && !isEffectivelyVisible(obj)) return; - if (obj.visible) { + if (targetObjects && targetObjects.length) { + // Fit only the supplied objects (selection-driven fit in app code) + for (const obj of targetObjects) { + if (!obj) continue; + if (visibleOnly && !isEffectivelyVisible(obj)) continue; box.expandByObject(obj); hasObjects = true; } - }); + } else { + // Recursively expand box for all visible objects with geometry + WORLD.traverse(obj => { + if (!obj.geometry) return; + if (visibleOnly && !isEffectivelyVisible(obj)) return; + if (obj.visible) { + box.expandByObject(obj); + hasObjects = true; + } + }); + } // If no objects, fall back to platform bounds if (!hasObjects) { @@ -1690,7 +1713,7 @@ let Space = { const newPanX = center.x; const newPanY = center.y; const newPanZ = center.z; - const currentScaleSave = viewControl.getPosition(true).scale || 1; + const currentScaleSave = viewControl.getPosition({ scaled: true }).scale || 1; const currentDistToCenter = camera.position.distanceTo(center); const fitPos = { diff --git a/src/void/solid/chamfer.js b/src/void/solid/chamfer.js index 1e1f0c81..159b672f 100644 --- a/src/void/solid/chamfer.js +++ b/src/void/solid/chamfer.js @@ -693,7 +693,7 @@ async function applyChamferFeature(solids, meshCache, feature, makeBodyId, bodyS solids.splice(targetIndex, 1, nextSolid); meshCache.delete(solidId); meshCache.set(nextId, result.mesh); - console.log('void.chamfer.applied', { + if (false) console.log('void.chamfer.applied', { featureId: feature?.id, solidId, cutters: tools.length, diff --git a/src/void/toolbar.js b/src/void/toolbar.js index eb3273c6..de3d9ca4 100644 --- a/src/void/toolbar.js +++ b/src/void/toolbar.js @@ -675,7 +675,7 @@ const toolbar = { this.docNameEl.textContent = name; this.docNameEl.title = name; } - document.title = `${name} - Void:Form`; + document.title = `${name} | Void:Form`; }, buildOpenDialog() { diff --git a/web/kiri/index.css b/web/kiri/index.css index 6150a59a..88cd2dc5 100644 --- a/web/kiri/index.css +++ b/web/kiri/index.css @@ -859,6 +859,11 @@ details[open] summary::after { z-index: 1000; } +.dark #curtain { + color: white; + background-color: black; +} + #tracker { top: 0; left: 0; diff --git a/web/kiri/index.html b/web/kiri/index.html index f8e7c959..e555ba4f 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -17,6 +17,12 @@ Kiri:Moto + diff --git a/web/mesh/index.css b/web/mesh/index.css index 218f306c..5b018c00 100644 --- a/web/mesh/index.css +++ b/web/mesh/index.css @@ -1,11 +1,10 @@ :root { + --accent: #5a9fd4; --menu-blue: #0079ff; - --menu-back: rgba(255,255,255,0.55); - --dark-menu-back: rgba(80,80,80,0.75); - --border: #888; - --dark-border: #888; - --selected: rgba(0,255,0,0.5); - --selected-hover: rgba(0,255,0,0.8); + --menu-back: rgba(60,60,60,0.75); + --border: #444; + --selected: rgba(90,159,212,0.5); + --selected-hover: rgba(90,159,212,1); } @font-face { @@ -54,12 +53,18 @@ input { font-family: sans-serif; font-weight: normal; font-size: larger; + background: #0f1116; bottom: 0; right: 0; left: 0; top: 0; } +#app.booting #top, +#app.booting #app-body { + pointer-events: none; +} + #app-body { flex-grow: 1; } @@ -75,8 +80,13 @@ input { right: 0; bottom: 0; position: fixed; - background-color: #fff; + background: radial-gradient(circle at 50% 40%, #1a2436 0%, #0f1116 60%, #0a0c11 100%); + color: #d7deea; font-family: 'Russo One', sans-serif; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 1px 2px rgba(0,0,0,0.35); justify-content: center; align-items: center; } @@ -84,6 +94,7 @@ input { #container { z-index: 1; position: fixed; + background: #0f1116; width: 100%; height: 100%; } @@ -94,14 +105,9 @@ input { overflow: hidden; } -.dark #top { - color: #eee; - background-color: var(--dark-menu-back); -} - #top { z-index: 50; - color: #000; + color: #eee; font-size: 14px; flex-direction: row; background-color: var(--menu-back); @@ -126,6 +132,10 @@ input { margin-bottom: -14px !important; } +#modal button { + border-radius: 3px; +} + button { padding: 2px 3px 2px 3px; border: 1px solid rgba(150,150,150,0.5); @@ -134,12 +144,8 @@ button { outline: none; } -.dark button:hover { - background-color: rgba(220,220,220,1); -} - button:hover { - background-color: rgba(210,210,210,1); + background-color: rgba(220,220,220,1); } /** misc ui **/ @@ -168,47 +174,85 @@ button:hover { padding: 0 !important; } -.menu, #top-right > div { +.title { + cursor: default; + align-self: stretch; + align-items: center; + margin: 4px 0 4px 0; + padding: 4px 12px 4px 12px; + border-radius: 0; + user-select: none; + font-weight: bold; + color: var(--accent); +} + +.menubar-separator { + align-self: stretch; + align-items: center; + background: rgba(255,255,255,0.2); + margin: 5px; + width: 1px; +} + +.toolbar-separator { + align-self: stretch; + align-items: center; + background: rgba(255,255,255,0.2); + margin: 2px; + height: 1px; +} + +.menu, #top-doc-name, #top-mode-label { cursor: default; align-self: stretch; align-items: center; padding: 8px 12px 8px 12px; + border: 1px solid transparent; border-radius: 6px; user-select: none; + margin: 2px; } -.dark .menu hr { +.menu hr { border-top: 0.5px solid rgba(255,255,255,0.5); } .menu:hover, .menu-items > div:hover, .tools i:hover { - background-color: var(--menu-blue); + border: 1px solid var(--accent); + background-color: rgba(0,0,0,0.5); } .menu:hover .menu-items { display: flex; } -.dark .menu-items { - background: #666; - border: 1px solid #999; -} - .menu-items { display: none; position: absolute; flex-direction: column; - border: 1px solid #bbb; + border: 1px solid #999; border-radius: 6px; - background: #eee; + background: #333; padding: 4px; - top: 100%; + top: calc(100% + 3px); left: 0; gap: 6px; } +/* invisible bridge between menu label and pop menu */ +.menu-items::before { + content: ""; + position: absolute; + top: -20px; + left: -10px; + right: -10px; + height: calc(100% + 20px); + background: transparent; +} + .menu-items > div { gap: 15px; + border: 1px solid transparent; border-radius: 6px; padding: 4px 8px 4px 8px; } @@ -221,39 +265,6 @@ button:hover { display: flex; } -#mode-label { - display: none; - position: absolute; - text-transform: capitalize; - padding: 2px 4px 2px 4px; - background-color: rgba(0,200,0,0.35); - border: 1px solid rgba(0,0,0,0.25); - border-radius: 3px; - transform: translateX(-50%); - top: calc(100% + 2px); - left: 50%; -} - -#mode-label:before { - content: ''; - position: absolute; - top: -10px; /* Adjust to position the caret above the menu */ - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent #777 transparent; -} - -.dark #mode-label:before { - border-color: transparent transparent #ddd transparent; -} - -.dark #mode-label { - background-color: rgba(0,200,0,0.65); - border-color: rgba(255,255,255,0.25); -} - #top-mid { flex-grow: 1; } @@ -268,6 +279,30 @@ button:hover { gap: 8px; } +#top-mode-label { + color: var(--accent); + white-space: nowrap; + justify-content: flex-end; + text-transform: capitalize; +} + +#top-doc-name { + cursor: pointer; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-content: flex-end; + border-color: #404040; + background-color: #202020; + transition: border-color 0.15s, color 0.15s; +} + +#top-doc-name:hover { + border-color: var(--accent); + background-color: rgba(0,0,0,0.5); +} + /** specific modal dialogs */ .export { @@ -313,6 +348,52 @@ button:hover { text-align: center; } +.doc-open-list { + display: flex; + flex-direction: column; + min-width: 420px; + max-height: 60vh; + overflow: auto; + gap: 6px; +} + +.doc-open-row { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--menu-border, #bbb); + border-radius: 6px; + padding: 6px 8px; +} + +.doc-open-row:hover { + background-color: var(--menu-blue); +} + +.doc-open-name { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-open-empty { + padding: 8px; + text-align: center; + opacity: 0.7; +} + +.doc-open-new { + width: 100%; +} + +.doc-open-del { + min-width: 28px; + padding: 2px 8px; + font-weight: 700; +} + .image-import { flex-direction: column; gap: 3px; @@ -348,12 +429,18 @@ button:hover { } #modal_frame { - border-radius: 4px !important; - background-color: #fff !important; + background-color: rgba(30,30,30,0.9); + border: 1px solid rgba(200,200,200,0.6); + border-radius: 4px; + color: white; flex-direction: column; - padding: 0 !important; /* override common below */ - /* z-index: 51; */ gap: 2px; + margin: 0 0 2px 0; + padding: 3px; +} + +#modal_frame a { + color: var(--accent); } #modal_frame > div { @@ -362,10 +449,10 @@ button:hover { } #modal_title { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom: 1px solid gray; - background-color: rgba(0,0,0,0.2); + color: white; + border-radius: 2px; + border-bottom: 1px solid rgba(0,0,0,0.2); + background-color: rgba(0,0,0,0.7); font-family: 'Russo One', monospace; font-size: smaller; justify-content: center; @@ -374,13 +461,13 @@ button:hover { #modal_title_close { position: absolute; - color: #555; + color: #999; right: 3px; top: 2px; } #modal_title_close:hover { - color: black; + color: #eee; } /** welcome dialog **/ @@ -394,13 +481,12 @@ button:hover { } .welcome a { - color: #038; border-radius: 3px; - padding: 0 10px 0 10px; + padding: 2px 10px 2px 10px; } .welcome a:hover { - background-color: #ddd; + background-color: #111; } .welcome .choice { @@ -412,18 +498,18 @@ button:hover { .settings { gap: 5px; display: grid; - background-color: #fff; + /* background-color: #fff; */ grid: min-content 1fr / min-content 1fr; grid-template-columns: 1fr 1fr 1fr; } .settings > div { display: grid; - background-color: #f5f5f5; + background-color: rgba(0,0,0,0.1); grid: min-content 1fr / min-content 1fr; white-space: nowrap; align-items: center; - border: 1px solid #ddd; + border: 1px solid rgba(100,100,100,0.7); border-radius: 3px; padding: 3px; } @@ -433,7 +519,7 @@ button:hover { text-align: center; margin-bottom: 5px; border-radius: 3px; - background-color: #ccc; + background-color: rgba(100,100,100,0.8); padding: 0; } @@ -477,12 +563,8 @@ button:hover { /** common look & feel */ -.dark #modal_frame, .dark #actions > div, .dark #grouplist > div { - border: 1px solid #999; -} - -#modal_frame, #actions > div, #grouplist > div { - border: 1px solid gray; +#actions > div, #grouplist > div { + border: 1px solid #555; border-radius: 3px; margin: 0 0 2px 0; padding: 3px; @@ -502,24 +584,18 @@ button:hover { background-color: var(--selected-hover); } -/* .dark .head { +/* .head { color: #000 !important; } */ /** slide in/out logging window **/ -.dark #logger { - border-color: var(--dark-border); - background-color: var(--dark-menu-back); - color: #ddd; -} - #logger { border-top: 1px solid var(--border); border-right: 1px solid var(--border); background-color: var(--menu-back); display: none; - color: #555; + color: #ddd; min-width: 300px; flex-direction: column; position: absolute; @@ -561,28 +637,24 @@ button:hover { border-right: 1px solid var(--border); } -.dark .tools { - border-color: var(--dark-border); - background-color: var(--dark-menu-back); -} - -.dark .tools > div { - color: #fff; -} - .tools { align-items: flex-start; background-color: var(--menu-back); + padding-top: 2px; } .tools > div { + color: #fff; display: grid; grid-template-columns: 1fr; + margin: 2px; } .tools i { padding: 9px; - border-bottom: 1px solid var(--border); + border: 1px solid transparent; + border-radius: 6px; + /* border-bottom: 1px solid var(--border); */ } .tool > div { @@ -597,11 +669,6 @@ button:hover { width: 100%; } -.dark .tool label { - background-color: var(--dark-menu-back); - border: 1px solid var(--dark-border); -} - .tool label:before { content: ''; position: absolute; @@ -610,10 +677,6 @@ button:hover { transform: translateY(-50%); border-width: 6px; border-style: solid; - border-color: transparent #777 transparent transparent; -} - -.dark .tool label:before { border-color: transparent #ddd transparent transparent; } @@ -715,14 +778,10 @@ button:hover { margin: 0; } -.dark #grouplist .models .square > svg { - color: #333; -} - #grouplist .models .square > svg { aspect-ratio: 1; max-height: 20px; - color: #666; + color: #333; } #grouplist .models > div { @@ -851,21 +910,15 @@ button:hover { transform: translate(-50%, 50%); } -.dark #pinner { - background-color: rgba(255,255,255,0.15); - border-color: rgba(255,255,255,0.25); - border-top-color: rgba(255,255,255,0.6); -} - #pinner { z-index: 5000; display: none; width: 50px; height: 50px; - background-color: rgba(0,0,0,0.1); - border: 12px solid rgba(0,0,0,0.15); + background-color: rgba(255,255,255,0.15); + border: 12px solid rgba(255,255,255,0.25); border-radius: 50%; - border-top-color: rgba(0,0,0,0.6); + border-top-color: rgba(255,255,255,0.6); animation: spin 1.5s linear infinite; } diff --git a/web/void/style.css b/web/void/style.css index 02fa2b78..8180fe83 100644 --- a/web/void/style.css +++ b/web/void/style.css @@ -88,7 +88,6 @@ html, body { .toolbar-title { font-weight: 600; font-size: 16px; - margin-right: 12px; color: var(--accent); }