UNPKG

@arminmajerie/dockview

Version:

Zero dependency layout manager supporting tabs, grids and splitviews (SolidJS only)

363 lines (362 loc) 12.7 kB
// 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; } }