mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
/**
|
|
* Properties Panel - Visual editor for nodes, focus options, and graph settings
|
|
* Similar to Photoshop's properties panel
|
|
*/
|
|
export class PropertiesPanel {
|
|
/** @type {HTMLElement} */
|
|
#panel;
|
|
|
|
/** @type {(() => void) | null} */
|
|
#onChange = null;
|
|
|
|
/** @type {any} */
|
|
#currentNode = null;
|
|
|
|
/** @type {any} */
|
|
#graphData = null;
|
|
|
|
/**
|
|
* @param {HTMLElement} panel
|
|
* @param {(() => void) | null} [onChange]
|
|
*/
|
|
constructor(panel, onChange = null) {
|
|
this.#panel = panel;
|
|
this.#onChange = onChange;
|
|
|
|
this.#bindEvents();
|
|
}
|
|
|
|
#bindEvents() {
|
|
// Toggle button (close)
|
|
const toggleBtn = this.#panel.querySelector('.properties-toggle-btn');
|
|
toggleBtn?.addEventListener('click', () => {
|
|
this.#panel.classList.toggle('collapsed');
|
|
});
|
|
|
|
// Tab button (open when collapsed)
|
|
const tabBtn = this.#panel.querySelector('.properties-panel-tab');
|
|
tabBtn?.addEventListener('click', () => {
|
|
this.#panel.classList.remove('collapsed');
|
|
});
|
|
|
|
// Collapsible sections
|
|
this.#panel.querySelectorAll('.section-header.collapsible').forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
header.classList.toggle('collapsed');
|
|
});
|
|
});
|
|
|
|
// Node property inputs
|
|
document.getElementById('propNodeTitle')?.addEventListener('input', () => this.#updateNodeProperty('title'));
|
|
document.getElementById('propNodeX')?.addEventListener('input', () => this.#updateNodeProperty('x'));
|
|
document.getElementById('propNodeY')?.addEventListener('input', () => this.#updateNodeProperty('y'));
|
|
document.getElementById('propNodeWidth')?.addEventListener('input', () => this.#updateNodeProperty('width'));
|
|
|
|
// Focus options inputs
|
|
document.getElementById('propFocusDuration')?.addEventListener('input', () => this.#updateFocusOptions());
|
|
document.getElementById('propFocusAnchorX')?.addEventListener('input', () => this.#updateFocusOptions());
|
|
document.getElementById('propFocusAnchorY')?.addEventListener('input', () => this.#updateFocusOptions());
|
|
document.getElementById('propWorldBoxWidth')?.addEventListener('input', () => this.#updateFocusOptions());
|
|
document.getElementById('propWorldBoxHeight')?.addEventListener('input', () => this.#updateFocusOptions());
|
|
|
|
// Add breakpoint button
|
|
document.getElementById('addBreakpointBtn')?.addEventListener('click', () => this.#addBreakpoint());
|
|
|
|
// Apply settings button
|
|
document.getElementById('applySettingsBtn')?.addEventListener('click', () => this.#applyGraphSettings());
|
|
|
|
// Graph settings
|
|
document.getElementById('propGraphDraggable')?.addEventListener('change', () => {
|
|
if (this.#graphData?.settings) {
|
|
this.#graphData.settings.draggable = document.getElementById('propGraphDraggable').checked;
|
|
this.#onChange?.();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load a node into the properties panel
|
|
*
|
|
* @param {any} node
|
|
*/
|
|
loadNode(node) {
|
|
this.#currentNode = node;
|
|
|
|
if (!node) {
|
|
document.getElementById('nodePropertiesSection').style.display = 'none';
|
|
document.getElementById('focusOptionsSection').style.display = 'none';
|
|
document.getElementById('noSelectionMessage').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('nodePropertiesSection').style.display = 'block';
|
|
document.getElementById('focusOptionsSection').style.display = 'block';
|
|
document.getElementById('noSelectionMessage').style.display = 'none';
|
|
|
|
// Populate node properties
|
|
const idInput = document.getElementById('propNodeId');
|
|
const typeInput = document.getElementById('propNodeType');
|
|
const titleInput = document.getElementById('propNodeTitle');
|
|
const xInput = document.getElementById('propNodeX');
|
|
const yInput = document.getElementById('propNodeY');
|
|
const widthInput = document.getElementById('propNodeWidth');
|
|
|
|
if (idInput) idInput.value = node.id || '';
|
|
if (typeInput) typeInput.value = node.type || '';
|
|
if (titleInput) titleInput.value = node.title || '';
|
|
if (xInput) xInput.value = node.position?.x ?? 0;
|
|
if (yInput) yInput.value = node.position?.y ?? 0;
|
|
if (widthInput) widthInput.value = node.width || '';
|
|
|
|
// Populate focus options
|
|
const focusOpts = node.focusOptions || {};
|
|
const durationInput = document.getElementById('propFocusDuration');
|
|
const anchorXInput = document.getElementById('propFocusAnchorX');
|
|
const anchorYInput = document.getElementById('propFocusAnchorY');
|
|
const boxWidthInput = document.getElementById('propWorldBoxWidth');
|
|
const boxHeightInput = document.getElementById('propWorldBoxHeight');
|
|
|
|
if (durationInput) durationInput.value = focusOpts.durationMs || '';
|
|
if (anchorXInput) anchorXInput.value = focusOpts.anchorX ?? '';
|
|
if (anchorYInput) anchorYInput.value = focusOpts.anchorY ?? '';
|
|
if (boxWidthInput) boxWidthInput.value = focusOpts.minWorldBox?.width || '';
|
|
if (boxHeightInput) boxHeightInput.value = focusOpts.minWorldBox?.height || '';
|
|
|
|
// Render breakpoints
|
|
this.#renderBreakpoints();
|
|
}
|
|
|
|
/**
|
|
* Load graph settings
|
|
*
|
|
* @param {any} graphData
|
|
*/
|
|
loadGraphSettings(graphData) {
|
|
this.#graphData = graphData;
|
|
|
|
const draggableCheck = document.getElementById('propGraphDraggable');
|
|
const introDurationInput = document.getElementById('propIntroDuration');
|
|
const viewXInput = document.getElementById('propInitViewX');
|
|
const viewYInput = document.getElementById('propInitViewY');
|
|
const viewWidthInput = document.getElementById('propInitViewWidth');
|
|
const viewHeightInput = document.getElementById('propInitViewHeight');
|
|
|
|
const settings = graphData?.settings || {};
|
|
const viewbox = settings.initialViewbox || {};
|
|
|
|
if (draggableCheck) draggableCheck.checked = settings.draggable ?? true;
|
|
if (introDurationInput) introDurationInput.value = settings.introDurationMs || '';
|
|
if (viewXInput) viewXInput.value = viewbox.x || '';
|
|
if (viewYInput) viewYInput.value = viewbox.y || '';
|
|
if (viewWidthInput) viewWidthInput.value = viewbox.width || '';
|
|
if (viewHeightInput) viewHeightInput.value = viewbox.height || '';
|
|
}
|
|
|
|
#updateNodeProperty(prop) {
|
|
if (!this.#currentNode) return;
|
|
|
|
const titleInput = document.getElementById('propNodeTitle');
|
|
const xInput = document.getElementById('propNodeX');
|
|
const yInput = document.getElementById('propNodeY');
|
|
const widthInput = document.getElementById('propNodeWidth');
|
|
|
|
if (prop === 'title' && titleInput) {
|
|
this.#currentNode.title = titleInput.value;
|
|
} else if (prop === 'x' && xInput) {
|
|
this.#currentNode.position.x = parseFloat(xInput.value) || 0;
|
|
} else if (prop === 'y' && yInput) {
|
|
this.#currentNode.position.y = parseFloat(yInput.value) || 0;
|
|
} else if (prop === 'width' && widthInput) {
|
|
this.#currentNode.width = widthInput.value ? parseFloat(widthInput.value) : undefined;
|
|
}
|
|
|
|
this.#onChange?.();
|
|
}
|
|
|
|
#updateFocusOptions() {
|
|
if (!this.#currentNode) return;
|
|
|
|
if (!this.#currentNode.focusOptions) {
|
|
this.#currentNode.focusOptions = {};
|
|
}
|
|
|
|
const durationInput = document.getElementById('propFocusDuration');
|
|
const anchorXInput = document.getElementById('propFocusAnchorX');
|
|
const anchorYInput = document.getElementById('propFocusAnchorY');
|
|
const boxWidthInput = document.getElementById('propWorldBoxWidth');
|
|
const boxHeightInput = document.getElementById('propWorldBoxHeight');
|
|
|
|
if (durationInput?.value) {
|
|
this.#currentNode.focusOptions.durationMs = parseFloat(durationInput.value);
|
|
}
|
|
if (anchorXInput?.value) {
|
|
this.#currentNode.focusOptions.anchorX = parseFloat(anchorXInput.value);
|
|
}
|
|
if (anchorYInput?.value) {
|
|
this.#currentNode.focusOptions.anchorY = parseFloat(anchorYInput.value);
|
|
}
|
|
|
|
if (boxWidthInput?.value || boxHeightInput?.value) {
|
|
if (!this.#currentNode.focusOptions.minWorldBox) {
|
|
this.#currentNode.focusOptions.minWorldBox = {};
|
|
}
|
|
if (boxWidthInput?.value) {
|
|
this.#currentNode.focusOptions.minWorldBox.width = parseFloat(boxWidthInput.value);
|
|
}
|
|
if (boxHeightInput?.value) {
|
|
this.#currentNode.focusOptions.minWorldBox.height = parseFloat(boxHeightInput.value);
|
|
}
|
|
}
|
|
|
|
this.#onChange?.();
|
|
}
|
|
|
|
#renderBreakpoints() {
|
|
const container = document.getElementById('breakpointsList');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
const responsiveBoxes = this.#currentNode?.focusOptions?.responsiveWorldBox;
|
|
if (!responsiveBoxes) return;
|
|
|
|
const breakpoints = Array.isArray(responsiveBoxes) ? responsiveBoxes : [responsiveBoxes];
|
|
|
|
breakpoints.forEach((bp, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'breakpoint-item';
|
|
item.innerHTML = `
|
|
<div class="breakpoint-header">
|
|
<div class="breakpoint-title">Breakpoint ${index + 1}</div>
|
|
<button class="breakpoint-remove" data-index="${index}">Remove</button>
|
|
</div>
|
|
<div class="prop-row">
|
|
<label class="prop-label">Min Viewport Width</label>
|
|
<input type="number" class="prop-input bp-min-width" data-index="${index}" value="${bp.minViewportWidth || 0}" />
|
|
</div>
|
|
<div class="prop-row">
|
|
<label class="prop-label">WorldBox Width</label>
|
|
<input type="number" class="prop-input bp-box-width" data-index="${index}" value="${bp.minWorldBox?.width || ''}" />
|
|
</div>
|
|
<div class="prop-row">
|
|
<label class="prop-label">WorldBox Height</label>
|
|
<input type="number" class="prop-input bp-box-height" data-index="${index}" value="${bp.minWorldBox?.height || ''}" />
|
|
</div>
|
|
<div class="prop-row">
|
|
<label class="prop-label">Anchor X</label>
|
|
<input type="number" class="prop-input bp-anchor-x" data-index="${index}" step="0.1" min="0" max="1" value="${bp.anchorX ?? ''}" />
|
|
</div>
|
|
<div class="prop-row">
|
|
<label class="prop-label">Anchor Y</label>
|
|
<input type="number" class="prop-input bp-anchor-y" data-index="${index}" step="0.1" min="0" max="1" value="${bp.anchorY ?? ''}" />
|
|
</div>
|
|
`;
|
|
container.appendChild(item);
|
|
|
|
// Bind events
|
|
item.querySelector('.breakpoint-remove')?.addEventListener('click', () => this.#removeBreakpoint(index));
|
|
item.querySelectorAll('input').forEach(input => {
|
|
input.addEventListener('input', () => this.#updateBreakpoint(index));
|
|
});
|
|
});
|
|
}
|
|
|
|
#addBreakpoint() {
|
|
if (!this.#currentNode) return;
|
|
|
|
if (!this.#currentNode.focusOptions) {
|
|
this.#currentNode.focusOptions = {};
|
|
}
|
|
|
|
if (!this.#currentNode.focusOptions.responsiveWorldBox) {
|
|
this.#currentNode.focusOptions.responsiveWorldBox = [];
|
|
} else if (!Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)) {
|
|
this.#currentNode.focusOptions.responsiveWorldBox = [this.#currentNode.focusOptions.responsiveWorldBox];
|
|
}
|
|
|
|
this.#currentNode.focusOptions.responsiveWorldBox.push({
|
|
minViewportWidth: 768,
|
|
minWorldBox: { width: 800, height: 600 },
|
|
anchorX: 0.5,
|
|
anchorY: 0.5
|
|
});
|
|
|
|
this.#renderBreakpoints();
|
|
this.#onChange?.();
|
|
}
|
|
|
|
#removeBreakpoint(index) {
|
|
if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return;
|
|
|
|
const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)
|
|
? this.#currentNode.focusOptions.responsiveWorldBox
|
|
: [this.#currentNode.focusOptions.responsiveWorldBox];
|
|
|
|
breakpoints.splice(index, 1);
|
|
|
|
if (breakpoints.length === 0) {
|
|
delete this.#currentNode.focusOptions.responsiveWorldBox;
|
|
} else {
|
|
this.#currentNode.focusOptions.responsiveWorldBox = breakpoints;
|
|
}
|
|
|
|
this.#renderBreakpoints();
|
|
this.#onChange?.();
|
|
}
|
|
|
|
#updateBreakpoint(index) {
|
|
if (!this.#currentNode?.focusOptions?.responsiveWorldBox) return;
|
|
|
|
const breakpoints = Array.isArray(this.#currentNode.focusOptions.responsiveWorldBox)
|
|
? this.#currentNode.focusOptions.responsiveWorldBox
|
|
: [this.#currentNode.focusOptions.responsiveWorldBox];
|
|
|
|
const bp = breakpoints[index];
|
|
if (!bp) return;
|
|
|
|
const minWidthInput = document.querySelector(`.bp-min-width[data-index="${index}"]`);
|
|
const boxWidthInput = document.querySelector(`.bp-box-width[data-index="${index}"]`);
|
|
const boxHeightInput = document.querySelector(`.bp-box-height[data-index="${index}"]`);
|
|
const anchorXInput = document.querySelector(`.bp-anchor-x[data-index="${index}"]`);
|
|
const anchorYInput = document.querySelector(`.bp-anchor-y[data-index="${index}"]`);
|
|
|
|
if (minWidthInput?.value) bp.minViewportWidth = parseFloat(minWidthInput.value);
|
|
|
|
if (!bp.minWorldBox) bp.minWorldBox = {};
|
|
if (boxWidthInput?.value) bp.minWorldBox.width = parseFloat(boxWidthInput.value);
|
|
if (boxHeightInput?.value) bp.minWorldBox.height = parseFloat(boxHeightInput.value);
|
|
|
|
if (anchorXInput?.value) bp.anchorX = parseFloat(anchorXInput.value);
|
|
if (anchorYInput?.value) bp.anchorY = parseFloat(anchorYInput.value);
|
|
|
|
this.#onChange?.();
|
|
}
|
|
|
|
#applyGraphSettings() {
|
|
if (!this.#graphData) return;
|
|
|
|
const introDurationInput = document.getElementById('propIntroDuration');
|
|
const viewXInput = document.getElementById('propInitViewX');
|
|
const viewYInput = document.getElementById('propInitViewY');
|
|
const viewWidthInput = document.getElementById('propInitViewWidth');
|
|
const viewHeightInput = document.getElementById('propInitViewHeight');
|
|
|
|
if (!this.#graphData.settings) this.#graphData.settings = {};
|
|
if (!this.#graphData.settings.initialViewbox) this.#graphData.settings.initialViewbox = {};
|
|
|
|
if (introDurationInput?.value) {
|
|
this.#graphData.settings.introDurationMs = parseFloat(introDurationInput.value);
|
|
}
|
|
|
|
const viewbox = this.#graphData.settings.initialViewbox;
|
|
if (viewXInput?.value) viewbox.x = parseFloat(viewXInput.value);
|
|
if (viewYInput?.value) viewbox.y = parseFloat(viewYInput.value);
|
|
if (viewWidthInput?.value) viewbox.width = parseFloat(viewWidthInput.value);
|
|
if (viewHeightInput?.value) viewbox.height = parseFloat(viewHeightInput.value);
|
|
|
|
this.#onChange?.();
|
|
|
|
// Show confirmation
|
|
const btn = document.getElementById('applySettingsBtn');
|
|
if (btn) {
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Applied ✓';
|
|
btn.style.background = 'rgba(60, 180, 100, 1)';
|
|
setTimeout(() => {
|
|
btn.textContent = originalText;
|
|
btn.style.background = '';
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
/** @returns {boolean} */
|
|
isCollapsed() {
|
|
return this.#panel.classList.contains('collapsed');
|
|
}
|
|
|
|
/** Collapse the panel */
|
|
collapse() {
|
|
this.#panel.classList.add('collapsed');
|
|
}
|
|
|
|
/** Expand the panel */
|
|
expand() {
|
|
this.#panel.classList.remove('collapsed');
|
|
}
|
|
}
|