feat(splitscreen): add split-screen feature for multi-app side-by-side view

Add new @manacore/shared-splitscreen package enabling iFrame-based
split-screen functionality across Calendar, Todo, and Contacts apps.

Features:
- SplitPaneContainer with CSS Grid layout
- AppPanel with iFrame sandbox permissions and loading/error states
- ResizeHandle with mouse, touch, and keyboard support (20-80% range)
- PanelControls for swap and close actions
- Svelte 5 runes-based store with Context API
- URL persistence (?panel=todo&split=60)
- localStorage persistence with versioning
- Mobile auto-disable (<1024px breakpoint)

Integration:
- PillNavigation: added onOpenInPanel prop and Ctrl/Cmd+click support
- PillDropdown: added split button per app item
- Calendar, Todo, Contacts layouts wrapped with SplitPaneContainer

Also fixes:
- WeekView.svelte: fixed {@const} placement error
- MultiDayView.svelte: fixed {@const} placement error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-12 13:00:26 +01:00
parent f51708d75a
commit f2ac3e245e
27 changed files with 2770 additions and 531 deletions

View file

@ -0,0 +1,13 @@
/**
* Split-Screen Utilities
* Re-export all utility functions.
*/
export { parseUrlState, updateUrlState, clearUrlState, getCurrentUrlState } from './url-state.js';
export {
savePanelState,
loadPanelState,
clearPanelState,
createStorageConfig,
} from './local-storage.js';

View file

@ -0,0 +1,97 @@
/**
* LocalStorage Utilities
* Handle persistent storage for split-screen preferences.
*/
import type { SplitScreenState, StorageConfig } from '../types.js';
import { DIVIDER_CONSTRAINTS } from '../types.js';
const STORAGE_VERSION = 1;
interface StoredState {
version: number;
state: Partial<SplitScreenState>;
}
/**
* Generate storage key for an app.
*/
function getStorageKey(config: StorageConfig): string {
return `${config.prefix}-splitscreen-${config.currentAppId}`;
}
/**
* Save split-screen state to localStorage.
*/
export function savePanelState(config: StorageConfig, state: Partial<SplitScreenState>): void {
if (typeof window === 'undefined') return;
try {
const stored: StoredState = {
version: STORAGE_VERSION,
state: {
dividerPosition: state.dividerPosition,
rightPanel: state.rightPanel,
},
};
localStorage.setItem(getStorageKey(config), JSON.stringify(stored));
} catch (_error) {
// localStorage not available or quota exceeded
}
}
/**
* Load split-screen state from localStorage.
*/
export function loadPanelState(config: StorageConfig): Partial<SplitScreenState> | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(getStorageKey(config));
if (!raw) return null;
const stored: StoredState = JSON.parse(raw);
// Version check for future migrations
if (stored.version !== STORAGE_VERSION) {
clearPanelState(config);
return null;
}
// Validate divider position
if (stored.state.dividerPosition !== undefined) {
stored.state.dividerPosition = Math.max(
DIVIDER_CONSTRAINTS.MIN,
Math.min(DIVIDER_CONSTRAINTS.MAX, stored.state.dividerPosition)
);
}
return stored.state;
} catch (_error) {
// localStorage not available or corrupted data
return null;
}
}
/**
* Clear split-screen state from localStorage.
*/
export function clearPanelState(config: StorageConfig): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(config));
} catch (_error) {
// localStorage not available
}
}
/**
* Get default storage config with manacore prefix.
*/
export function createStorageConfig(currentAppId: string): StorageConfig {
return {
prefix: 'manacore',
currentAppId,
};
}

View file

@ -0,0 +1,65 @@
/**
* URL State Utilities
* Handle URL-based state persistence for split-screen.
*/
import type { UrlState } from '../types.js';
/**
* Parse split-screen state from URL search params.
* Reads `?panel=todo&split=60` format.
*/
export function parseUrlState(searchParams: URLSearchParams): UrlState {
const panel = searchParams.get('panel') || undefined;
const splitStr = searchParams.get('split');
const split = splitStr ? parseInt(splitStr, 10) : undefined;
return {
panel,
split: split && !isNaN(split) ? split : undefined,
};
}
/**
* Update URL with split-screen state without page reload.
* Uses replaceState to avoid adding to browser history.
*/
export function updateUrlState(state: UrlState): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (state.panel) {
url.searchParams.set('panel', state.panel);
} else {
url.searchParams.delete('panel');
}
if (state.split && state.split !== 50) {
url.searchParams.set('split', state.split.toString());
} else {
url.searchParams.delete('split');
}
window.history.replaceState({}, '', url.toString());
}
/**
* Clear split-screen state from URL.
*/
export function clearUrlState(): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.delete('panel');
url.searchParams.delete('split');
window.history.replaceState({}, '', url.toString());
}
/**
* Get current URL state.
*/
export function getCurrentUrlState(): UrlState {
if (typeof window === 'undefined') return {};
return parseUrlState(new URLSearchParams(window.location.search));
}