Add application feedback and refine headless canvas design boundaries
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
import { useImageUploads } from "./use-image-uploads";
|
||||
import { notify, dismissNotification } from "@/lib/notifications";
|
||||
import { FeedbackContext } from "@/components/ui/app-feedback";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
import { displayID } from "@/lib/canvas-tree";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, NodeToolbar, Position, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
|
||||
@@ -60,6 +62,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [demoImageUploading, setImageUploading] = useState(false);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
@@ -122,6 +125,18 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [membershipNotice]);
|
||||
useEffect(() => {
|
||||
if (!feedback || !error) return;
|
||||
const id = `canvas:${board.id}:error`;
|
||||
notify({ id, level: "error", title: error });
|
||||
setError("");
|
||||
}, [feedback, error, board.id]);
|
||||
useEffect(() => {
|
||||
if (!feedback || !membershipNotice) return;
|
||||
const id = `canvas:${board.id}:membership`;
|
||||
notify({ id, level: "success", title: membershipNotice });
|
||||
return () => dismissNotification(id);
|
||||
}, [feedback, membershipNotice, board.id]);
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||
@@ -526,7 +541,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />}
|
||||
<div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>
|
||||
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
|
||||
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
@@ -542,7 +557,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</NodeToolbar>;
|
||||
}))}
|
||||
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
|
||||
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode } from "react";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const FeedbackContext = createContext(false);
|
||||
|
||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||
return <FeedbackContext.Provider value={true}>
|
||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||
{children}
|
||||
<Toaster position="bottom-right" closeButton visibleToasts={3} duration={4500}
|
||||
offset={{ bottom: 24, right: 20 }} containerAriaLabel="操作通知"
|
||||
toastOptions={{ closeButtonAriaLabel: "关闭通知", style: { background: "var(--popover, #fffcf9)", color: "var(--popover-foreground, #38281f)", border: "1px solid var(--border, #ded5cc)" } }} />
|
||||
</Tooltip.Provider>
|
||||
</FeedbackContext.Provider>;
|
||||
}
|
||||
@@ -17,3 +17,6 @@
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
|
||||
.arrow { fill: #49372e; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { FeedbackContext } from "./app-feedback";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import styles from "./icon-button.module.css";
|
||||
|
||||
@@ -10,18 +12,25 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, className = "", type = "button", ...props }, ref) {
|
||||
return (
|
||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const button = (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
title={feedback ? undefined : title}
|
||||
className={`${styles.button} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
if (!feedback || !title) return button;
|
||||
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
|
||||
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>
|
||||
{title}<Tooltip.Arrow className={styles.arrow} />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal></Tooltip.Root>;
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user