mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 14:49:24 +02:00
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:
parent
f51708d75a
commit
f2ac3e245e
27 changed files with 2770 additions and 531 deletions
251
packages/shared-splitscreen/src/stores/split-panel.svelte.ts
Normal file
251
packages/shared-splitscreen/src/stores/split-panel.svelte.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Split-Panel Store
|
||||
* Svelte 5 runes-based state management for split-screen panels.
|
||||
*/
|
||||
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { PanelConfig, AppDefinition, StorageConfig } from '../types.js';
|
||||
import { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from '../types.js';
|
||||
import { savePanelState, loadPanelState, createStorageConfig } from '../utils/local-storage.js';
|
||||
import { updateUrlState, clearUrlState, getCurrentUrlState } from '../utils/url-state.js';
|
||||
|
||||
const SPLIT_PANEL_CONTEXT_KEY = Symbol('split-panel');
|
||||
|
||||
/**
|
||||
* Available apps that can be opened in split-screen.
|
||||
*/
|
||||
export const DEFAULT_APPS: AppDefinition[] = [
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar',
|
||||
baseUrl: 'http://localhost:5179',
|
||||
icon: 'calendar',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
baseUrl: 'http://localhost:5188',
|
||||
icon: 'check-square',
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
baseUrl: 'http://localhost:5184',
|
||||
icon: 'users',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'clock',
|
||||
name: 'Clock',
|
||||
baseUrl: 'http://localhost:5187',
|
||||
icon: 'clock',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
];
|
||||
|
||||
export interface SplitPanelStore {
|
||||
// State
|
||||
readonly isActive: boolean;
|
||||
readonly rightPanel: PanelConfig | null;
|
||||
readonly dividerPosition: number;
|
||||
readonly isMobile: boolean;
|
||||
|
||||
// Available apps (excluding current)
|
||||
readonly availableApps: AppDefinition[];
|
||||
|
||||
// Actions
|
||||
openPanel: (appId: string, path?: string) => void;
|
||||
closePanel: () => void;
|
||||
swapPanels: () => void;
|
||||
setDividerPosition: (position: number) => void;
|
||||
resetDividerPosition: () => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a split-panel store for an app.
|
||||
*/
|
||||
export function createSplitPanelStore(
|
||||
currentAppId: string,
|
||||
apps: AppDefinition[] = DEFAULT_APPS
|
||||
): SplitPanelStore {
|
||||
// Reactive state using Svelte 5 runes
|
||||
let isActive = $state(false);
|
||||
let rightPanel = $state<PanelConfig | null>(null);
|
||||
let dividerPosition = $state(DIVIDER_CONSTRAINTS.DEFAULT);
|
||||
let isMobile = $state(false);
|
||||
|
||||
// Storage config for persistence
|
||||
const storageConfig: StorageConfig = createStorageConfig(currentAppId);
|
||||
|
||||
// Filter out current app from available apps
|
||||
const availableApps = $derived(apps.filter((app) => app.id !== currentAppId));
|
||||
|
||||
/**
|
||||
* Open an app in the right panel.
|
||||
*/
|
||||
function openPanel(appId: string, path = '/'): void {
|
||||
if (isMobile) return;
|
||||
|
||||
const app = apps.find((a) => a.id === appId);
|
||||
if (!app || app.id === currentAppId) return;
|
||||
|
||||
const url = `${app.baseUrl}${path}`;
|
||||
|
||||
rightPanel = {
|
||||
appId: app.id,
|
||||
url,
|
||||
name: app.name,
|
||||
};
|
||||
isActive = true;
|
||||
|
||||
// Persist to URL and localStorage
|
||||
updateUrlState({ panel: appId, split: dividerPosition });
|
||||
savePanelState(storageConfig, { rightPanel, dividerPosition, isActive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the split panel.
|
||||
*/
|
||||
function closePanel(): void {
|
||||
rightPanel = null;
|
||||
isActive = false;
|
||||
|
||||
// Clear persistence
|
||||
clearUrlState();
|
||||
savePanelState(storageConfig, { rightPanel: null, dividerPosition, isActive: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap left and right panels (navigate to the right panel app).
|
||||
*/
|
||||
function swapPanels(): void {
|
||||
if (!rightPanel) return;
|
||||
|
||||
// Navigate to the other app
|
||||
const targetUrl = rightPanel.url;
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the divider position.
|
||||
*/
|
||||
function setDividerPosition(position: number): void {
|
||||
const clamped = Math.max(DIVIDER_CONSTRAINTS.MIN, Math.min(DIVIDER_CONSTRAINTS.MAX, position));
|
||||
dividerPosition = clamped;
|
||||
|
||||
// Persist
|
||||
if (isActive) {
|
||||
updateUrlState({ panel: rightPanel?.appId, split: clamped });
|
||||
savePanelState(storageConfig, { rightPanel, dividerPosition: clamped, isActive });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset divider to default position.
|
||||
*/
|
||||
function resetDividerPosition(): void {
|
||||
setDividerPosition(DIVIDER_CONSTRAINTS.DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize from URL and localStorage.
|
||||
*/
|
||||
function initialize(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Check mobile
|
||||
const checkMobile = () => {
|
||||
isMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||
if (isMobile && isActive) {
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
// Load from URL first, then localStorage
|
||||
const urlState = getCurrentUrlState();
|
||||
const storedState = loadPanelState(storageConfig);
|
||||
|
||||
const panelAppId = urlState.panel || storedState?.rightPanel?.appId;
|
||||
const savedPosition = urlState.split || storedState?.dividerPosition;
|
||||
|
||||
if (panelAppId && !isMobile) {
|
||||
const app = apps.find((a) => a.id === panelAppId);
|
||||
if (app && app.id !== currentAppId) {
|
||||
openPanel(panelAppId);
|
||||
if (savedPosition) {
|
||||
setDividerPosition(savedPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the store interface with getters for reactive access
|
||||
return {
|
||||
get isActive() {
|
||||
return isActive;
|
||||
},
|
||||
get rightPanel() {
|
||||
return rightPanel;
|
||||
},
|
||||
get dividerPosition() {
|
||||
return dividerPosition;
|
||||
},
|
||||
get isMobile() {
|
||||
return isMobile;
|
||||
},
|
||||
get availableApps() {
|
||||
return availableApps;
|
||||
},
|
||||
openPanel,
|
||||
closePanel,
|
||||
swapPanels,
|
||||
setDividerPosition,
|
||||
resetDividerPosition,
|
||||
initialize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the split-panel store in Svelte context.
|
||||
* Call this in your layout component.
|
||||
*/
|
||||
export function setSplitPanelContext(
|
||||
currentAppId: string,
|
||||
apps: AppDefinition[] = DEFAULT_APPS
|
||||
): SplitPanelStore {
|
||||
const store = createSplitPanelStore(currentAppId, apps);
|
||||
setContext(SPLIT_PANEL_CONTEXT_KEY, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the split-panel store from Svelte context.
|
||||
* Call this in child components.
|
||||
*/
|
||||
export function getSplitPanelContext(): SplitPanelStore {
|
||||
const store = getContext<SplitPanelStore>(SPLIT_PANEL_CONTEXT_KEY);
|
||||
if (!store) {
|
||||
throw new Error(
|
||||
'[SplitScreen] No split-panel context found. Did you call setSplitPanelContext in a parent component?'
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if split-panel context exists.
|
||||
*/
|
||||
export function hasSplitPanelContext(): boolean {
|
||||
try {
|
||||
getContext(SPLIT_PANEL_CONTEXT_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue