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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user