@arminmajerie/dockview
Version:
Zero dependency layout manager supporting tabs, grids and splitviews (SolidJS only)
363 lines (362 loc) • 12.7 kB
JSX
// packages/dockview/src/splitview/splitview.tsx
import { createEffect, createSignal, onCleanup } from "solid-js";
import { createSplitview, PROPERTY_KEYS_SPLITVIEW, } from '@arminmajerie/dockview-core';
import { usePortalsLifecycle } from "../solid";
import { SolidPanelView } from "./view";
/* ---------- Helpers ---------- */
function extractCoreOptions(props) {
const core = PROPERTY_KEYS_SPLITVIEW.reduce((acc, key) => {
if (key in props) {
acc[key] = props[key];
}
return acc;
}, {});
return core;
}
function createRatioPersistence(props, hostEl) {
if (!props.persistRatio) {
return {
load: () => null,
save: () => { },
shouldPersist: () => false,
};
}
const storage = props.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
if (!storage) {
console.warn('Splitview: persistRatio enabled but no storage available');
return {
load: () => null,
save: () => { },
shouldPersist: () => false,
};
}
// Generate storage key
const getStorageKey = () => {
if (props.storageKey) {
return `splitview_ratio_${props.storageKey}`;
}
// Fallback: try to generate from host element id or use a hash of component names
const el = hostEl();
if (el?.id) {
return `splitview_ratio_${el.id}`;
}
// Last resort: hash component names (stable if components don't change)
const componentHash = Object.keys(props.components).sort().join('_');
return `splitview_ratio_${componentHash}`;
};
const load = () => {
try {
const key = getStorageKey();
const stored = storage.getItem(key);
if (!stored)
return null;
const ratio = Number(stored);
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) {
return null;
}
// Ignore suspicious extremes
if (ratio <= 0.05 || ratio >= 0.95) {
return null;
}
return ratio;
}
catch (e) {
console.error('Splitview: Failed to load persisted ratio', e);
return null;
}
};
const save = (ratio) => {
try {
const clamped = Math.max(0.05, Math.min(0.95, ratio));
const key = getStorageKey();
storage.setItem(key, String(clamped));
}
catch (e) {
console.error('Splitview: Failed to save ratio', e);
}
};
return {
load,
save,
shouldPersist: () => true,
};
}
/* ---------- Component ---------- */
export function SplitviewSolid(props) {
// Use a signal to track the host element reactively
const [hostEl, setHostEl] = createSignal(undefined);
let api;
let ro;
const [portals, addPortal] = usePortalsLifecycle();
// Track last seen SplitviewOptions to send only changes
let prevOptions = {};
// Ratio persistence state
let saveEnabled = false;
let userAdjusting = false;
let wrapResizeSettled = true;
let adjustResetTimer;
let wrapSettleTimer;
let firstPanelEl;
let panelRO;
// Store cleanup function at component level
let persistenceCleanup;
const persistence = createRatioPersistence(props, () => hostEl());
const attachPersistenceHandlers = () => {
const el = hostEl();
if (!persistence.shouldPersist() || !el || !api)
return;
// Track user mouse interactions
const onMouseDown = () => {
userAdjusting = true;
if (adjustResetTimer)
window.clearTimeout(adjustResetTimer);
adjustResetTimer = window.setTimeout(() => {
userAdjusting = false;
}, 1500);
};
const onMouseUp = () => {
userAdjusting = false;
if (adjustResetTimer)
window.clearTimeout(adjustResetTimer);
};
try {
el.addEventListener('mousedown', onMouseDown, { passive: true });
window.addEventListener('mouseup', onMouseUp, { passive: true });
}
catch (e) {
console.error('Splitview: Failed to attach mouse handlers', e);
}
// Track wrapper resize to avoid persisting during layout thrash
const onWrapResize = () => {
wrapResizeSettled = false;
if (wrapSettleTimer)
window.clearTimeout(wrapSettleTimer);
wrapSettleTimer = window.setTimeout(() => {
wrapResizeSettled = true;
}, 300);
};
if (ro) {
// Enhance existing ResizeObserver callback
ro.disconnect();
ro = new ResizeObserver((entries) => {
// Original resize logic
const currentEl = hostEl();
if (api && currentEl) {
api.layout(currentEl.clientWidth, currentEl.clientHeight);
}
// Persistence resize tracking
onWrapResize();
});
ro.observe(el);
}
// Observe first panel to detect ratio changes
const observeFirstPanel = () => {
try {
panelRO?.disconnect();
}
catch (e) {
console.error('Splitview: Failed to disconnect panel observer', e);
}
// Get first panel element from API
const panels = api.panels;
if (!panels || panels.length === 0)
return;
firstPanelEl = panels[0]?.view?.element;
const currentEl = hostEl();
if (!firstPanelEl || !currentEl)
return;
panelRO = new ResizeObserver(() => {
if (!saveEnabled || !wrapResizeSettled || !userAdjusting)
return;
const el = hostEl();
if (!el || !firstPanelEl)
return;
const total = props.orientation === 'VERTICAL'
? el.clientHeight
: el.clientWidth;
const first = props.orientation === 'VERTICAL'
? firstPanelEl.clientHeight
: firstPanelEl.clientWidth;
if (total <= 1)
return;
const ratio = first / total;
persistence.save(ratio);
});
panelRO.observe(firstPanelEl);
};
// Listen for panel changes
api.onDidAddView(() => {
observeFirstPanel();
});
api.onDidRemoveView(() => {
observeFirstPanel();
});
observeFirstPanel();
return () => {
try {
el?.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
panelRO?.disconnect();
if (adjustResetTimer)
window.clearTimeout(adjustResetTimer);
if (wrapSettleTimer)
window.clearTimeout(wrapSettleTimer);
}
catch (e) {
console.error('Splitview: Failed to cleanup persistence handlers', e);
}
};
};
// Track if we've initialized the splitview
let initialized = false;
let layoutScheduled = false;
// Helper to wait for element to have real dimensions
const waitForDimensions = (el, callback, maxAttempts = 50) => {
let attempts = 0;
const check = () => {
attempts++;
if (el.clientWidth > 0 && el.clientHeight > 0) {
callback();
}
else if (attempts < maxAttempts) {
requestAnimationFrame(check);
}
else {
// Fallback: call anyway after max attempts (element might legitimately be 0 width)
callback();
}
};
requestAnimationFrame(check);
};
// Use createEffect to initialize when hostEl becomes available
createEffect(() => {
const el = hostEl();
if (!el || initialized)
return;
initialized = true;
const frameworkOptions = {
createComponent: (options) => new SolidPanelView(options.id, options.name, props.components[options.name], { addPortal }),
};
api = createSplitview(el, {
...extractCoreOptions(props),
...frameworkOptions,
});
// Wait for element to have real dimensions before first layout
waitForDimensions(el, () => {
const currentEl = hostEl();
if (!api || !currentEl)
return;
api.layout(currentEl.clientWidth, currentEl.clientHeight);
// Setup persistence after initial layout - store cleanup
persistenceCleanup = attachPersistenceHandlers();
// Enable saving only after layout stabilizes
requestAnimationFrame(() => {
requestAnimationFrame(() => {
saveEnabled = true;
});
});
props.onReady?.({ api });
});
if (!props.disableAutoResizing && "ResizeObserver" in window) {
ro = new ResizeObserver(() => {
const currentEl = hostEl();
if (!api || !currentEl)
return;
api.layout(currentEl.clientWidth, currentEl.clientHeight);
});
ro.observe(el);
}
});
// Update createComponent if components registry identity changes
createEffect(() => {
if (!api)
return;
api.updateOptions({
createComponent: (options) => new SolidPanelView(options.id, options.name, props.components[options.name], { addPortal }),
});
});
// Reactively update SplitviewOptions (orientation, margin, proportionalLayout, styles, descriptor, ...)
createEffect(() => {
if (!api)
return;
const changes = {};
for (const k of PROPERTY_KEYS_SPLITVIEW) {
const nextVal = props[k];
if (nextVal !== prevOptions[k]) {
changes[k] = nextVal;
}
}
if (Object.keys(changes).length > 0) {
api.updateOptions(changes);
prevOptions = { ...prevOptions, ...changes };
const el = hostEl();
if (el)
api.layout(el.clientWidth, el.clientHeight);
}
});
onCleanup(() => {
ro?.disconnect();
ro = undefined;
panelRO?.disconnect();
panelRO = undefined;
api?.dispose();
api = undefined;
if (adjustResetTimer)
window.clearTimeout(adjustResetTimer);
if (wrapSettleTimer)
window.clearTimeout(wrapSettleTimer);
// Call persistence cleanup if it was set
if (persistenceCleanup) {
persistenceCleanup();
persistenceCleanup = undefined;
}
});
const hostClass = () => props.class ?? props.className ?? undefined;
return (<div ref={setHostEl} class={hostClass()} style={props.style}>
{/*{portals()}*/}
</div>);
}
/* ---------- Export helper for programmatic ratio restoration ---------- */
/**
* Helper to load a saved split ratio.
* Returns the ratio (0-1) if found, or null if not found/invalid.
* Use this when you need to manually set panel sizes in onReady.
*
* @example
* ```tsx
* <SplitviewSolid
* persistRatio={true}
* storageKey="my-split"
* onReady={({ api }) => {
* const ratio = loadSplitRatio('my-split');
* const totalWidth = containerEl.clientWidth;
*
* api.addPanel({
* id: 'left',
* component: 'left',
* size: ratio ? totalWidth * ratio : 300
* });
* api.addPanel({ id: 'right', component: 'right' });
* }}
* />
* ```
*/
export function loadSplitRatio(storageKey, storage) {
const store = storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
if (!store)
return null;
try {
const key = `splitview_ratio_${storageKey}`;
const stored = store.getItem(key);
if (!stored)
return null;
const ratio = Number(stored);
if (!Number.isFinite(ratio) || ratio <= 0.05 || ratio >= 0.95)
return null;
return ratio;
}
catch (e) {
console.error('Splitview: Failed to load ratio', e);
return null;
}
}