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,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>