fix(webui): let Safari add widgets and reorder without HTML5 drag

Touch cannot reliably start a drag. Add a tap control for the
gallery, a file-input label, and handle-only reorder.
This commit is contained in:
DrSmoothl
2026-09-19 16:30:11 +08:00
parent feea35da4c
commit 6efadce2eb
5 changed files with 167 additions and 90 deletions
@@ -4,7 +4,7 @@
// server, or a browser upload that we stream to a server temp path (and then
// feed that path to the action). Upload progress flows through the app-level
// task registry so it survives tab switches.
import { useRef, useState } from 'react';
import { useId, useState } from 'react';
import { FileUp, Link2, Loader2, ServerCog, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useApi } from '@/lib/api';
@@ -15,9 +15,9 @@ import type { FieldRole } from '@/types';
type Mode = 'url' | 'server' | 'upload';
const ACCEPT: Partial<Record<FieldRole, string>> = {
image: 'image/*',
record: 'audio/*',
video: 'video/*',
image: 'image/*,.png,.jpg,.jpeg,.gif,.webp,.bmp,.heic,.heif',
record: 'audio/*,.silk,.amr,.mp3,.wav,.m4a,.ogg',
video: 'video/*,.mp4,.mov,.mkv,.webm',
};
function Seg({ active, onClick, icon, label }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string }) {
@@ -45,7 +45,7 @@ export function FileSource({ value, onChange, role, placeholder }: {
const [uploading, setUploading] = useState(false);
const [uploadName, setUploadName] = useState<string | null>(null);
const [uploadErr, setUploadErr] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
const accept = role ? ACCEPT[role] : undefined;
const doUpload = async (file: File) => {
@@ -93,20 +93,25 @@ export function FileSource({ value, onChange, role, placeholder }: {
{mode === 'upload' && (
<div className="flex flex-col gap-1.5">
<input
ref={inputRef}
id={inputId}
type="file"
accept={accept}
className="hidden"
className="sr-only"
onChange={(e) => { const f = e.target.files?.[0]; if (f) void doUpload(f); e.target.value = ''; }}
/>
<button
type="button"
disabled={uploading}
onClick={() => inputRef.current?.click()}
className={cn('flex items-center justify-center gap-2 rounded-xl border border-dashed border-border/70 px-3 py-3 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:opacity-60')}
<label
htmlFor={inputId}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file && !uploading) void doUpload(file);
}}
className={cn('flex items-center justify-center gap-2 rounded-xl border border-dashed border-border/70 px-3 py-3 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground', uploading && 'pointer-events-none opacity-60')}
>
{uploading ? <><Loader2 className="h-4 w-4 animate-spin" /> 上传中…</> : <><FileUp className="h-4 w-4" /> 选择文件上传到服务器</>}
</button>
{uploading ? <><Loader2 className="h-4 w-4 animate-spin" /> 上传中…</> : <><FileUp className="h-4 w-4" /> 选择或拖入文件上传到服务器</>}
</label>
{value && uploadName && !uploading && (
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-2.5 py-1.5 text-meta">
<span className="truncate text-muted-foreground">已上传:{uploadName}</span>
@@ -205,7 +205,12 @@ export function MessageBuilder({ segments, onChange, uin, groupId, depth = 0, mo
// stopPropagation keeps a nested (forward-node) builder's drag
// from reaching the parent row; onDragEnd lives here because the
// grip — not the row — is now the drag source.
onDragStart={(e) => { e.stopPropagation(); setDragFrom(i); }}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData('text/plain', String(i));
e.dataTransfer.effectAllowed = 'move';
setDragFrom(i);
}}
onDragEnd={() => setDragFrom(null)}
title="拖动此处排序" aria-label="拖动排序"
className="flex shrink-0 cursor-grab rounded-md p-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing"
@@ -1,5 +1,5 @@
import { Bug, Check, Eye, EyeOff, GripVertical, LayoutDashboard, Lock, Pin, PinOff, PlugZap, Settings, Sparkles, SlidersHorizontal, Terminal } from 'lucide-react';
import { motion, Reorder } from 'motion/react';
import { motion, Reorder, useDragControls } from 'motion/react';
import { Link, useRouterState } from '@tanstack/react-router';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
@@ -9,6 +9,7 @@ import { useTheme } from '@/contexts/ThemeContext';
import { reconcileLayoutItems, useLayout } from '@/contexts/LayoutContext';
import { useMediaQuery } from '@/hooks/use-media-query';
import type { AppPath } from '@/router';
import type { UiLayoutItem } from '@/types';
export interface NavItem {
to: AppPath;
@@ -32,6 +33,58 @@ export const NAV_ITEMS: NavItem[] = [
// '/settings'— account + appearance.
export const PINNED_NAV: AppPath[] = ['/', '/settings'];
function NavReorderRow({
item,
meta,
onToggle,
}: {
item: UiLayoutItem;
meta: NavItem;
onToggle: (id: string) => void;
}) {
const controls = useDragControls();
const Icon = meta.icon;
const itemPinned = (PINNED_NAV as string[]).includes(item.id);
return (
<Reorder.Item
value={item.id}
dragListener={false}
dragControls={controls}
className={cn(
'flex select-none items-center gap-2 rounded-lg bg-sidebar-accent/40 px-2 py-2',
!item.visible && 'opacity-50',
)}
>
<button
type="button"
aria-label="拖动排序"
className="inline-flex size-8 shrink-0 touch-none items-center justify-center text-muted-foreground"
onPointerDown={(e) => controls.start(e)}
>
<GripVertical className="size-3.5" />
</button>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{meta.label}</span>
{itemPinned ? (
<span title="必选项,不可隐藏" className="inline-flex size-8 items-center justify-center text-muted-foreground/50">
<Lock className="size-3.5" />
</span>
) : (
<button
type="button"
onClick={() => onToggle(item.id)}
onPointerDown={(e) => e.stopPropagation()}
title={item.visible ? '隐藏' : '显示'}
aria-label={item.visible ? '隐藏' : '显示'}
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/50 hover:text-foreground cursor-pointer"
>
{item.visible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</button>
)}
</Reorder.Item>
);
}
interface SidebarProps {
collapsed?: boolean;
onItemClick?: () => void;
@@ -98,36 +151,13 @@ export function Sidebar({ collapsed = false, onItemClick }: SidebarProps) {
{reconciled.map((item) => {
const meta = NAV_ITEMS.find((n) => n.to === item.id);
if (!meta) return null;
const Icon = meta.icon;
const itemPinned = (PINNED_NAV as string[]).includes(item.id);
return (
<Reorder.Item
<NavReorderRow
key={item.id}
value={item.id}
className={cn(
'flex select-none items-center gap-2 rounded-lg bg-sidebar-accent/40 px-2 py-2 cursor-grab active:cursor-grabbing',
!item.visible && 'opacity-50',
)}
>
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm">{meta.label}</span>
{itemPinned ? (
<span title="必选项,不可隐藏" className="inline-flex size-7 items-center justify-center text-muted-foreground/50">
<Lock className="size-3.5" />
</span>
) : (
<button
type="button"
onClick={() => toggleNav(item.id)}
title={item.visible ? '隐藏' : '显示'}
aria-label={item.visible ? '隐藏' : '显示'}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent/50 hover:text-foreground cursor-pointer"
>
{item.visible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</button>
)}
</Reorder.Item>
item={item}
meta={meta}
onToggle={toggleNav}
/>
);
})}
</Reorder.Group>
@@ -78,6 +78,7 @@ export function DashboardGrid({
float: true, // free placement — items stay where dropped (no auto-compact)
disableDrag: !editing,
disableResize: !editing,
handle: '.grid-drag-handle',
// Drag a card onto the gallery to remove it (edit mode only). The whole
// card is the drag target; the corner icons are just hints.
removable: editing && removableSelector ? removableSelector : false,
@@ -150,30 +151,28 @@ export function DashboardGrid({
<div className="relative h-full w-full">
{editing && (
<>
{/* Subtle drag hint — a small square, not a bar. The whole card
is draggable; this just signals it (pointer-events-none so
the drag passes through to gridstack). */}
<span
aria-hidden
<button
type="button"
title="拖动卡片调整位置 · 拖回右侧部件库可移除"
className="pointer-events-none absolute left-1.5 top-1.5 z-20 inline-flex size-6 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-50 backdrop-blur-sm"
aria-label="拖动调整位置"
className="grid-drag-handle pointer-events-auto absolute left-1.5 top-1.5 z-20 inline-flex size-11 items-center justify-center rounded-md bg-background/80 text-muted-foreground touch-none outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
>
<GripVertical className="size-3.5" />
</span>
</button>
{configurableIds.has(b.id) && (
<button
type="button"
onClick={() => onConfigOpen(b.id)}
title="设置"
aria-label="设置"
className="pointer-events-auto absolute right-1.5 top-1.5 z-20 inline-flex size-6 items-center justify-center rounded-md bg-background/70 text-muted-foreground opacity-60 backdrop-blur-sm transition-[color,opacity,box-shadow] duration-150 ease-out outline-none hover:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/40 cursor-pointer"
className="pointer-events-auto absolute right-1.5 top-1.5 z-20 inline-flex size-11 items-center justify-center rounded-md bg-background/80 text-muted-foreground transition-[color,box-shadow] duration-150 ease-out outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40 cursor-pointer"
>
<Settings2 className="size-3.5" />
</button>
)}
</>
)}
<div className={cn('h-full w-full overflow-auto rounded-xl', editing && 'pointer-events-none select-none')}>
<div className={cn('h-full w-full overflow-auto overscroll-contain rounded-xl', editing && 'pointer-events-none select-none')}>
{renderWidget(b)}
</div>
</div>,
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
import { motion, Reorder } from 'motion/react';
import { motion, Reorder, useDragControls } from 'motion/react';
import { Link } from '@tanstack/react-router';
import {
Activity, ArrowRight, Bell, Cable, Check, Cpu, ExternalLink, Eye, EyeOff, GripVertical,
@@ -147,7 +147,7 @@ export function OverviewPage() {
{editing && (
<p className="rounded-xl border border-primary/20 bg-primary/5 px-3.5 py-2.5 text-xs leading-relaxed text-muted-foreground">
{isWide
? '拖动卡片移动位置、拖右下角缩放,右上齿轮改设置;把卡片拖回右侧「部件库」即可移除,从部件库拖到画布即可添加。左侧导航也可在编辑态拖动排序。'
? '拖动卡片移动位置、拖右下角缩放,右上齿轮改设置;把卡片拖回右侧「部件库」即可移除,从部件库点加号或拖到画布即可添加。左侧导航也可在编辑态拖动手柄排序。'
: '拖动手柄调整卡片顺序,眼睛图标显隐,齿轮改设置。手机为单列布局,与桌面网格各自独立。'}
</p>
)}
@@ -198,7 +198,7 @@ export function OverviewPage() {
/>
)}
</div>
{editing && <WidgetGallery hidden={hiddenBlocks} />}
{editing && <WidgetGallery hidden={hiddenBlocks} onAdd={addWidget} />}
</div>
) : (
<MobileOverview
@@ -243,6 +243,61 @@ function EmptyLayout({ onReset }: { onReset: () => void }) {
);
}
function MobileLayoutRow({
item,
onToggle,
onConfigOpen,
}: {
item: UiLayoutItem;
onToggle: (id: string) => void;
onConfigOpen: (id: string) => void;
}) {
const controls = useDragControls();
return (
<Reorder.Item
value={item.id}
dragListener={false}
dragControls={controls}
className={cn(
'flex select-none items-center gap-2 rounded-xl border bg-card/60 px-3 py-2.5',
!item.visible && 'opacity-50',
)}
>
<button
type="button"
aria-label="拖动排序"
className="inline-flex size-11 shrink-0 touch-none items-center justify-center text-muted-foreground"
onPointerDown={(e) => controls.start(e)}
>
<GripVertical className="size-4" />
</button>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{widgetLabel(item.id)}</span>
{CONFIGURABLE_WIDGETS.has(item.id) && item.visible && (
<button
type="button"
onClick={() => onConfigOpen(item.id)}
onPointerDown={(e) => e.stopPropagation()}
title="设置"
aria-label="设置"
className="inline-flex size-11 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer"
>
<Settings2 className="size-3.5" />
</button>
)}
<button
type="button"
onClick={() => onToggle(item.id)}
onPointerDown={(e) => e.stopPropagation()}
title={item.visible ? '隐藏' : '显示'}
aria-label={item.visible ? '隐藏' : '显示'}
className="inline-flex size-11 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer"
>
{item.visible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</button>
</Reorder.Item>
);
}
// ─────────────── mobile single-column overview ───────────────
// Phones get a one-dimensional layout (`overviewMobile`), fully disjoint from
// the desktop 2D grid. View mode reuses the grid renderer (drag disabled) so
@@ -262,37 +317,12 @@ function MobileOverview({
return (
<Reorder.Group axis="y" values={items.map((i) => i.id)} onReorder={onReorder} className="flex flex-col gap-2">
{items.map((item) => (
<Reorder.Item
<MobileLayoutRow
key={item.id}
value={item.id}
className={cn(
'flex select-none items-center gap-2 rounded-xl border bg-card/60 px-3 py-2.5 cursor-grab active:cursor-grabbing',
!item.visible && 'opacity-50',
)}
>
<GripVertical className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{widgetLabel(item.id)}</span>
{CONFIGURABLE_WIDGETS.has(item.id) && item.visible && (
<button
type="button"
onClick={() => onConfigOpen(item.id)}
title="设置"
aria-label="设置"
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer"
>
<Settings2 className="size-3.5" />
</button>
)}
<button
type="button"
onClick={() => onToggle(item.id)}
title={item.visible ? '隐藏' : '显示'}
aria-label={item.visible ? '隐藏' : '显示'}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer"
>
{item.visible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</button>
</Reorder.Item>
item={item}
onToggle={onToggle}
onConfigOpen={onConfigOpen}
/>
))}
</Reorder.Group>
);
@@ -332,7 +362,7 @@ function MobileOverview({
// tile onto the canvas adds it (HTML5 DnD); dragging a canvas card back onto
// this panel removes it (gridstack `removable`, matched by the .widget-gallery
// class). Edit-mode only, desktop only.
function WidgetGallery({ hidden }: { hidden: UiLayoutItem[] }) {
function WidgetGallery({ hidden, onAdd }: { hidden: UiLayoutItem[]; onAdd: (id: string, x: number, y: number) => void }) {
return (
<div className="widget-gallery w-56 shrink-0 self-stretch rounded-xl border border-dashed bg-muted/20 p-3">
<div className="mb-1.5 flex items-center gap-1.5 px-1">
@@ -340,7 +370,7 @@ function WidgetGallery({ hidden }: { hidden: UiLayoutItem[] }) {
<span className="text-xs font-semibold">部件库</span>
</div>
<p className="mb-3 px-1 text-xs leading-snug text-muted-foreground">
拖到左侧画布添加;把卡片拖回此处即可移除。
点加号或拖到左侧画布添加;把卡片拖回此处即可移除。
</p>
{hidden.length === 0 ? (
<div className="rounded-lg border border-dashed px-2 py-8 text-center text-xs text-muted-foreground">
@@ -360,7 +390,15 @@ function WidgetGallery({ hidden }: { hidden: UiLayoutItem[] }) {
>
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{widgetLabel(b.id)}</span>
<Plus className="size-3.5 shrink-0 text-muted-foreground/60" />
<button
type="button"
aria-label={`添加 ${widgetLabel(b.id)}`}
onClick={() => onAdd(b.id, 0, 0)}
onPointerDown={(e) => e.stopPropagation()}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus className="size-3.5" />
</button>
</div>
))}
</div>
@@ -383,7 +421,7 @@ function DropPlaceholder({ onAdd }: { onAdd: (id: string, x: number, y: number)
className="flex min-h-60 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed py-16 text-center text-muted-foreground"
>
<LayoutGrid className="size-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">从右侧「部件库」拖动部件到这里</p>
<p className="text-sm">从右侧「部件库」点加号或拖动部件到这里</p>
</div>
);
}