296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
const APP_VERSION = '0.5.2';
|
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
|
|
|
let tabs = [];
|
|
let activeTabId = null;
|
|
let tabIdCounter = 1;
|
|
let kindCounters = {};
|
|
let currentEditor = null;
|
|
|
|
function editorModuleFor(kind) {
|
|
if (kind === 'muur') return window.WallEditor;
|
|
if (kind === 'vloer') return window.FloorEditor;
|
|
if (kind === 'woning') return window.HouseEditor;
|
|
return null;
|
|
}
|
|
|
|
function defaultNameFor(kind) {
|
|
kindCounters[kind] = (kindCounters[kind] || 0) + 1;
|
|
return `${kind} ${String(kindCounters[kind]).padStart(2, '0')}`;
|
|
}
|
|
|
|
function setWorkspaceEmpty() {
|
|
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
|
<div>Geen ontwerp geopend.</div>
|
|
<div style="font-size:11px;">Bestand → Nieuw → kies een objecttype</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Ribbon (core/ribbon.js): app-tabbladen Bestand, Beeld en Extra; de
|
|
// editor voegt er zijn eigen tabbladen aan toe. Knoppen die de huidige
|
|
// editor niet ondersteunt staan uit.
|
|
function updateEditMenuState() {
|
|
Ribbon.refresh();
|
|
}
|
|
|
|
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
|
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
|
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
|
|
|
function appRibbonTabs() {
|
|
const { big, small } = Ribbon;
|
|
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
|
|
const a = b.dataset.app;
|
|
if (a === 'new') openNewModal();
|
|
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
|
|
else if (a === 'fullscreen') toggleBrowserFullscreen();
|
|
else if (a === 'costs') CostSettings.open();
|
|
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
|
|
else editorCall(a);
|
|
}));
|
|
return [
|
|
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
|
|
{ label: 'Document', html:
|
|
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
|
|
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
|
|
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
|
|
{ label: 'Snel nieuw', html:
|
|
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
|
|
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
|
|
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
|
|
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
|
|
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
|
|
] },
|
|
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
|
|
{ label: 'Venster', html:
|
|
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
|
|
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
|
|
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
|
|
{ label: 'Camera', html:
|
|
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
|
|
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
|
|
] },
|
|
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
|
|
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
|
|
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
|
|
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
|
|
] }
|
|
];
|
|
}
|
|
|
|
function renderTabBar() {
|
|
const bar = document.getElementById('tab-bar');
|
|
bar.innerHTML = tabs.map(tab => `
|
|
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
|
|
<span class="tab-name">${tab.name}</span>
|
|
<span class="tab-close" data-close-id="${tab.id}">×</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function onTabSaved(tab, filenameWithExt) {
|
|
const base = filenameWithExt.replace(/\.json$/i, '');
|
|
tab.name = base;
|
|
tab.savedName = base;
|
|
renderTabBar();
|
|
}
|
|
|
|
function mountEditorForTab(tab) {
|
|
const ws = document.getElementById('workspace');
|
|
ws.innerHTML = '';
|
|
Ribbon.setSource('editor', []);
|
|
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
|
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
|
currentEditor.loadStateJson(tab.stateJson);
|
|
}
|
|
}
|
|
|
|
function switchToTab(tabId) {
|
|
if (activeTabId === tabId) return;
|
|
const oldTab = tabs.find(t => t.id === activeTabId);
|
|
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
|
oldTab.stateJson = currentEditor.getStateJson();
|
|
}
|
|
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
|
currentEditor = null;
|
|
|
|
activeTabId = tabId;
|
|
mountEditorForTab(tabs.find(t => t.id === tabId));
|
|
updateEditMenuState();
|
|
renderTabBar();
|
|
}
|
|
|
|
function openNew(kind, template) {
|
|
if (!editorModuleFor(kind)) return; // 'dak' nog niet beschikbaar
|
|
|
|
const oldTab = tabs.find(t => t.id === activeTabId);
|
|
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
|
oldTab.stateJson = currentEditor.getStateJson();
|
|
}
|
|
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
|
currentEditor = null;
|
|
|
|
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null };
|
|
tabs.push(tab);
|
|
activeTabId = tab.id;
|
|
mountEditorForTab(tab);
|
|
updateEditMenuState();
|
|
renderTabBar();
|
|
}
|
|
|
|
function isTabDirty(tab) {
|
|
const module = editorModuleFor(tab.kind);
|
|
if (!module || typeof module.getDefaultState !== 'function') return false;
|
|
const state = (tab.id === activeTabId && currentEditor && typeof currentEditor.getStateJson === 'function')
|
|
? currentEditor.getStateJson()
|
|
: tab.stateJson;
|
|
if (!state) return false;
|
|
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
|
}
|
|
|
|
function closeTab(tabId) {
|
|
const tab = tabs.find(t => t.id === tabId);
|
|
if (!tab) return;
|
|
|
|
if (tab.id !== activeTabId) {
|
|
if (!isTabDirty(tab)) {
|
|
tabs = tabs.filter(t => t.id !== tabId);
|
|
renderTabBar();
|
|
return;
|
|
}
|
|
switchToTab(tabId);
|
|
}
|
|
|
|
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
|
const saved = currentEditor.exportDesign();
|
|
if (!saved) return;
|
|
}
|
|
|
|
tabs = tabs.filter(t => t.id !== tabId);
|
|
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
|
currentEditor = null;
|
|
activeTabId = null;
|
|
|
|
if (tabs.length > 0) {
|
|
switchToTab(tabs[tabs.length - 1].id);
|
|
} else {
|
|
setWorkspaceEmpty();
|
|
Ribbon.setSource('editor', []);
|
|
updateEditMenuState();
|
|
renderTabBar();
|
|
}
|
|
}
|
|
|
|
function wireTabBar() {
|
|
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
|
const closeBtn = e.target.closest('.tab-close');
|
|
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
|
const item = e.target.closest('.tab-item');
|
|
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
|
|
});
|
|
}
|
|
|
|
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
|
|
function renderLicenseHtml(md) {
|
|
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
return md.trim().split(/\n\s*\n/).map(block => {
|
|
const b = block.trim();
|
|
if (b.startsWith('## ')) return `<h4>${esc(b.slice(3))}</h4>`;
|
|
if (b.startsWith('# ')) return `<h3>${esc(b.slice(2))}</h3>`;
|
|
return `<p>${esc(b.replace(/\s*\n\s*/g, ' '))}</p>`;
|
|
}).join('');
|
|
}
|
|
|
|
function wireAbout() {
|
|
const overlay = document.getElementById('about-overlay');
|
|
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
|
|
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
|
}
|
|
|
|
function wireNewModal() {
|
|
const overlay = document.getElementById('new-overlay');
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
|
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
|
|
opt.addEventListener('click', () => {
|
|
openNew(opt.dataset.kind, opt.dataset.template);
|
|
overlay.classList.remove('open');
|
|
});
|
|
});
|
|
}
|
|
|
|
function openNewModal() {
|
|
document.getElementById('new-overlay').classList.add('open');
|
|
}
|
|
|
|
function toggleBrowserFullscreen() {
|
|
if (document.fullscreenElement) document.exitFullscreen();
|
|
else document.documentElement.requestFullscreen();
|
|
}
|
|
|
|
function wireHotkeys() {
|
|
document.addEventListener('keydown', (e) => {
|
|
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
|
|
|
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
|
e.preventDefault();
|
|
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
|
return;
|
|
}
|
|
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
|
e.preventDefault();
|
|
openNewModal();
|
|
return;
|
|
}
|
|
// Easter egg (shooter); e.code omdat Alt/Shift e.key per toetsenbord verandert.
|
|
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyL') {
|
|
e.preventDefault();
|
|
if (currentEditor && typeof currentEditor.toggleShooter === 'function') currentEditor.toggleShooter();
|
|
return;
|
|
}
|
|
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
|
|
if (e.key.toLowerCase() === 'f') {
|
|
toggleBrowserFullscreen();
|
|
} else if (e.key.toLowerCase() === 'h') {
|
|
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
|
} else if (e.key.toLowerCase() === 't' && e.shiftKey) {
|
|
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
|
} else if (e.key.toLowerCase() === 't') {
|
|
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
|
} else if (e.key.toLowerCase() === 'r') {
|
|
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
|
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
|
Ribbon.init(document.getElementById('ribbon'));
|
|
Ribbon.setSource('app', appRibbonTabs());
|
|
wireAbout();
|
|
wireNewModal();
|
|
wireHotkeys();
|
|
wireTabBar();
|
|
// Tectum draait via een webserver (texturen, lucht); file:// werkt niet volledig.
|
|
if (location.protocol === 'file:') {
|
|
const note = document.createElement('div');
|
|
note.className = 'ui-warn file-protocol-warning';
|
|
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
|
|
document.body.appendChild(note);
|
|
}
|
|
openNew('woning', 'L');
|
|
|
|
setTimeout(() => {
|
|
const splash = document.getElementById('splash-screen');
|
|
if (splash) splash.remove();
|
|
}, 4300);
|
|
});
|