mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 16:29:40 +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
155
packages/shared-splitscreen/src/components/AppPanel.svelte
Normal file
155
packages/shared-splitscreen/src/components/AppPanel.svelte
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppPanel Component
|
||||
* iFrame container for displaying an app in split-screen.
|
||||
*/
|
||||
|
||||
import type { PanelConfig } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
panel: PanelConfig;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { panel, class: className = '' }: Props = $props();
|
||||
|
||||
let isLoading = $state(true);
|
||||
let hasError = $state(false);
|
||||
|
||||
function handleLoad() {
|
||||
isLoading = false;
|
||||
hasError = false;
|
||||
}
|
||||
|
||||
function handleError() {
|
||||
isLoading = false;
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// iFrame sandbox permissions
|
||||
const sandboxPermissions = [
|
||||
'allow-same-origin',
|
||||
'allow-scripts',
|
||||
'allow-forms',
|
||||
'allow-popups',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
'allow-storage-access-by-user-activation',
|
||||
].join(' ');
|
||||
</script>
|
||||
|
||||
<div class="app-panel {className}">
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading {panel.name || panel.appId}...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasError}
|
||||
<div class="error-state">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>Failed to load {panel.name || panel.appId}</span>
|
||||
<button
|
||||
onclick={() => {
|
||||
isLoading = true;
|
||||
hasError = false;
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<iframe
|
||||
src={panel.url}
|
||||
title={panel.name || panel.appId}
|
||||
sandbox={sandboxPermissions}
|
||||
class:hidden={hasError}
|
||||
onload={handleLoad}
|
||||
onerror={handleError}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg-secondary, #1a1a1a);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: var(--color-bg-primary, #0a0a0a);
|
||||
}
|
||||
|
||||
iframe.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.error-state button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
117
packages/shared-splitscreen/src/components/PanelControls.svelte
Normal file
117
packages/shared-splitscreen/src/components/PanelControls.svelte
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* PanelControls Component
|
||||
* Controls overlay for split panel (swap, close).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
panelName: string;
|
||||
onSwap: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { panelName, onSwap, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel-controls">
|
||||
<span class="panel-label">{panelName}</span>
|
||||
|
||||
<div class="control-buttons">
|
||||
<button class="control-btn" title="Swap panels" onclick={onSwap}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 3l4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="M8 21l-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn close" title="Close panel" onclick={onClose}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel-controls {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-panel:hover .panel-controls,
|
||||
.panel-controls:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
197
packages/shared-splitscreen/src/components/ResizeHandle.svelte
Normal file
197
packages/shared-splitscreen/src/components/ResizeHandle.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ResizeHandle Component
|
||||
* Draggable divider for resizing split panels.
|
||||
*/
|
||||
|
||||
import { DIVIDER_CONSTRAINTS } from '../types.js';
|
||||
|
||||
interface Props {
|
||||
position: number;
|
||||
onResize: (position: number) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
let { position, onResize, onReset }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let containerRef: HTMLElement | null = null;
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef) return;
|
||||
|
||||
const container = containerRef.closest('.split-pane-container');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
|
||||
const clamped = Math.max(
|
||||
DIVIDER_CONSTRAINTS.MIN,
|
||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
||||
);
|
||||
|
||||
onResize(clamped);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef || !e.touches[0]) return;
|
||||
|
||||
const container = containerRef.closest('.split-pane-container');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const newPosition = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
|
||||
|
||||
const clamped = Math.max(
|
||||
DIVIDER_CONSTRAINTS.MIN,
|
||||
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
|
||||
);
|
||||
|
||||
onResize(clamped);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
onReset();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const step = event.shiftKey ? 10 : 2;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
onResize(Math.max(DIVIDER_CONSTRAINTS.MIN, position - step));
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
onResize(Math.min(DIVIDER_CONSTRAINTS.MAX, position + step));
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
onResize(DIVIDER_CONSTRAINTS.DEFAULT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="resize-handle"
|
||||
class:dragging={isDragging}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={position}
|
||||
aria-valuemin={DIVIDER_CONSTRAINTS.MIN}
|
||||
aria-valuemax={DIVIDER_CONSTRAINTS.MAX}
|
||||
tabindex="0"
|
||||
onmousedown={handleMouseDown}
|
||||
ontouchstart={handleTouchStart}
|
||||
ondblclick={handleDoubleClick}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<div class="handle-line"></div>
|
||||
<div class="handle-grip">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.resize-handle {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
var(--color-primary, #3b82f6) 20%,
|
||||
var(--color-primary, #3b82f6) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.resize-handle:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.resize-handle:focus-visible {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.handle-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.resize-handle:hover .handle-line,
|
||||
.resize-handle.dragging .handle-line {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.handle-grip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover .handle-grip,
|
||||
.resize-handle.dragging .handle-grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle-grip span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SplitPaneContainer Component
|
||||
* Main container that handles split-screen layout.
|
||||
*/
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { getSplitPanelContext } from '../stores/split-panel.svelte.js';
|
||||
import AppPanel from './AppPanel.svelte';
|
||||
import PanelControls from './PanelControls.svelte';
|
||||
import ResizeHandle from './ResizeHandle.svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { children, class: className = '' }: Props = $props();
|
||||
|
||||
const splitPanel = getSplitPanelContext();
|
||||
|
||||
// Grid template based on divider position
|
||||
let gridTemplate = $derived(
|
||||
splitPanel.isActive && splitPanel.rightPanel ? `${splitPanel.dividerPosition}% 6px 1fr` : '1fr'
|
||||
);
|
||||
|
||||
function handleResize(position: number) {
|
||||
splitPanel.setDividerPosition(position);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
splitPanel.resetDividerPosition();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="split-pane-container {className}"
|
||||
class:split-active={splitPanel.isActive && splitPanel.rightPanel}
|
||||
style:--grid-template={gridTemplate}
|
||||
>
|
||||
<div class="main-panel">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if splitPanel.isActive && splitPanel.rightPanel}
|
||||
<ResizeHandle
|
||||
position={splitPanel.dividerPosition}
|
||||
onResize={handleResize}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<div class="side-panel">
|
||||
<AppPanel panel={splitPanel.rightPanel} />
|
||||
<PanelControls
|
||||
panelName={splitPanel.rightPanel.name || splitPanel.rightPanel.appId}
|
||||
onSwap={() => splitPanel.swapPanels()}
|
||||
onClose={() => splitPanel.closePanel()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.split-pane-container {
|
||||
display: grid;
|
||||
grid-template-columns: var(--grid-template, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ensure proper stacking */
|
||||
.split-active .main-panel {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.split-active .side-panel {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hide side panel on mobile via media query as fallback */
|
||||
@media (max-width: 1023px) {
|
||||
.split-pane-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.side-panel,
|
||||
.split-pane-container :global(.resize-handle) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue