chore: release 0.0.1

This commit is contained in:
liaibo
2025-11-29 05:16:23 +08:00
parent 5d0e4c4cb6
commit d350b02fba
92 changed files with 78929 additions and 39164 deletions
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function POST(request: NextRequest) {
const supabase = await createSupabaseRouteClient();
const { event, session } = await request.json();
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
await supabase.auth.setSession(session);
}
if (event === "SIGNED_OUT") {
await supabase.auth.signOut();
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,34 @@
"use server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const url = new URL(request.url);
const gridKey = url.searchParams.get("gridKey");
if (!gridKey) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
.select("id,title,grid_key")
.eq("grid_key", gridKey)
.single();
if (error || !data) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
return NextResponse.json({
code: 200,
msg: "ok",
data: {
title: data.title ?? "未命名工作簿",
lang: "zh",
gridKey: data.grid_key,
},
});
}
@@ -0,0 +1,48 @@
"use server";
import { NextResponse } from "next/server";
import { createDefaultTableSnapshot } from "@/lib/online-table";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
const fallbackSchema: TableSchema = {
columns: [],
frozenRowCount: 0,
frozenColCount: 0,
};
export async function POST(request: Request) {
const url = new URL(request.url);
const gridKey = url.searchParams.get("gridKey");
if (!gridKey) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
.select("schema,snapshot")
.eq("grid_key", gridKey)
.single();
if (error || !data) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
const snapshot = (data.snapshot as DocumentTableSnapshot | null) ?? null;
const schema = (data.schema as TableSchema | null) ?? fallbackSchema;
let payload: unknown[] = [];
if (snapshot?.luckysheet && Array.isArray(snapshot.luckysheet)) {
payload = snapshot.luckysheet;
} else {
const defaultSnapshot = createDefaultTableSnapshot(schema);
payload = defaultSnapshot.luckysheet ?? [];
}
const body = JSON.stringify(payload);
return new NextResponse(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
@@ -0,0 +1,42 @@
"use server";
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
const BUCKET = "media";
const DIRECTORY = "luckysheet";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const formData = await request.formData();
const file = formData.get("image");
if (!(file instanceof File)) {
return NextResponse.json({ code: 400, msg: "缺少 image 文件" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = file.name?.split(".").pop() || "bin";
const objectPath = `${DIRECTORY}/${randomUUID()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from(BUCKET)
.upload(objectPath, buffer, { contentType: file.type || "application/octet-stream", upsert: false });
if (uploadError) {
return NextResponse.json({ code: 500, msg: "上传失败", detail: uploadError.message }, { status: 500 });
}
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET).getPublicUrl(objectPath);
return NextResponse.json({
code: 200,
msg: "ok",
url: publicUrl,
});
}
@@ -98,7 +98,7 @@ export async function PATCH(request: Request, context: RouteContext) {
try {
const { data: tableMeta, error: metaError } = await supabase
.from("document_tables")
.select("id, workspace_id, document_id")
.select("id, workspace_id, document_id, grid_key")
.eq("id", tableId)
.single();
+10
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
export default async function Home() {
const supabase = await createSupabaseServerClient();
@@ -11,10 +12,18 @@ export default async function Home() {
redirect("/login");
}
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
redirect("/login");
}
const { data: firstDoc } = await supabase
.from("documents")
.select("id")
.eq("user_id", session.user.id)
.eq("workspace_id", workspaceId)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
@@ -27,6 +36,7 @@ export default async function Home() {
.from("documents")
.insert({
user_id: session.user.id,
workspace_id: workspaceId,
title: "新页面",
content: {},
})
@@ -0,0 +1,23 @@
import HeadlessTableViewer from "@/components/online-table/HeadlessTableViewer";
interface TableViewerPageProps {
params: Promise<{ tableId: string }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
searchParams: Promise<Record<string, any>>;
}
export const dynamic = "force-dynamic";
export default async function TableViewerPage({ params, searchParams }: TableViewerPageProps) {
const resolvedParams = await params;
const resolvedSearch = await searchParams;
const tableId = resolvedParams.tableId;
const embedMode = resolvedSearch?.embed === "1";
return (
<div className={embedMode ? "min-h-screen bg-transparent" : "min-h-screen bg-white"}>
<HeadlessTableViewer tableId={tableId} embed={embedMode} />
</div>
);
}
@@ -170,6 +170,7 @@ export function BlockNoteEditor({
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null;
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -280,6 +281,7 @@ export function BlockNoteEditor({
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
isFullScreenTableOpen && "pointer-events-none select-none",
);
const buildDocumentPath = (documentId: string): string => {
@@ -575,11 +577,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
@@ -2,13 +2,42 @@
import { BlockNoteEditor, Block } from "@blocknote/core";
import { createReactBlockSpec } from "@blocknote/react";
import { OnlineTableBlockProps } from "@/types/online-table";
import { Table } from "lucide-react";
import React, { useCallback } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { CustomBlockSchema } from "../schema";
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
import { useEditorBridgeStore } from "@/store/editor-bridge";
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 420;
const MAX_WIDTH = 1400;
const MIN_HEIGHT = 320;
const MAX_HEIGHT = 900;
type ResizeHandle =
| "left"
| "right"
| "top"
| "bottom"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
const handleMapping: Record<
ResizeHandle,
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
> = {
left: { horizontal: "left" },
right: { horizontal: "right" },
top: { vertical: "top" },
bottom: { vertical: "bottom" },
"top-left": { horizontal: "left", vertical: "top" },
"top-right": { horizontal: "right", vertical: "top" },
"bottom-left": { horizontal: "left", vertical: "bottom" },
"bottom-right": { horizontal: "right", vertical: "bottom" },
};
// 占位符组件:在紧凑模式下渲染表格块
const OnlineTableBlockComponent = ({
block,
@@ -19,6 +48,34 @@ const OnlineTableBlockComponent = ({
}) => {
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
const [draftSize, setDraftSize] = useState({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
useEffect(() => {
setDraftSize({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
}, [storedWidth, storedHeight]);
const commitSize = useCallback(
(next: { width: number; height: number }) => {
setDraftSize(next);
editor.updateBlock(block, {
props: {
...block.props,
width: next.width,
height: next.height,
},
});
},
[block, block.props, editor],
);
// 阶段三:实现双击/按钮进入全屏编辑
const handleFullScreen = () => {
@@ -33,9 +90,151 @@ const OnlineTableBlockComponent = ({
editor.removeBlocks([block.id]);
}, [block.id, editor]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const startResize = useCallback(
(handle: ResizeHandle) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = draftSize.width;
const startHeight = draftSize.height;
let nextWidth = startWidth;
let nextHeight = startHeight;
const axes = handleMapping[handle];
setActiveHandle(handle);
document.body.style.userSelect = "none";
const cursor =
axes.horizontal && axes.vertical
? axes.horizontal === "left"
? axes.vertical === "top"
? "nwse-resize"
: "nesw-resize"
: axes.vertical === "top"
? "nesw-resize"
: "nwse-resize"
: axes.horizontal
? "ew-resize"
: "ns-resize";
document.body.style.cursor = cursor;
const handleMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (axes.horizontal === "left") {
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
} else if (axes.horizontal === "right") {
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
} else {
nextWidth = startWidth;
}
if (axes.vertical === "top") {
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else if (axes.vertical === "bottom") {
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else {
nextHeight = startHeight;
}
setDraftSize({
width: nextWidth,
height: nextHeight,
});
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setActiveHandle(null);
commitSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[commitSize, draftSize.height, draftSize.width],
);
const size = useMemo(
() => ({
width: clamp(draftSize.width, MIN_WIDTH, MAX_WIDTH),
height: clamp(draftSize.height, MIN_HEIGHT, MAX_HEIGHT),
}),
[draftSize.height, draftSize.width],
);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
activeHandle === handle ? "is-dragging" : ""
}`;
return (
<div className="w-full">
<CompactTablePreview tableId={tableId} onFullScreen={handleFullScreen} onDelete={handleDelete} />
<div className="w-full overflow-auto" contentEditable={false}>
<div
className="online-table-block group relative mx-auto"
style={{ width: size.width, minWidth: MIN_WIDTH }}
>
<CompactTablePreview
tableId={tableId}
onFullScreen={handleFullScreen}
onDelete={handleDelete}
height={size.height}
/>
<button
type="button"
aria-label="向左拖拽以调整宽度"
className={handleClass("left")}
onMouseDown={startResize("left")}
/>
<button
type="button"
aria-label="向右拖拽以调整宽度"
className={handleClass("right")}
onMouseDown={startResize("right")}
/>
<button
type="button"
aria-label="向上拖拽以调整高度"
className={handleClass("top")}
onMouseDown={startResize("top")}
/>
<button
type="button"
aria-label="向下拖拽以调整高度"
className={handleClass("bottom")}
onMouseDown={startResize("bottom")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-left")}
onMouseDown={startResize("top-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-right")}
onMouseDown={startResize("top-right")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-left")}
onMouseDown={startResize("bottom-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-right")}
onMouseDown={startResize("bottom-right")}
/>
</div>
</div>
);
};
@@ -47,6 +246,8 @@ export const onlineTableBlock = createReactBlockSpec(
propSchema: {
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
title: { default: "未命名表格" },
width: { default: DEFAULT_WIDTH },
height: { default: DEFAULT_HEIGHT },
},
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
},
@@ -1,19 +1,15 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { DocumentTable, TableColumn, TableRowData } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
DEFAULT_TABLE_ROWS,
deleteOnlineTable,
getDocumentTable,
} from "@/lib/online-table";
import { Loader2, Table, Maximize2, Trash2 } from "lucide-react";
import type { DocumentTable } from "@/types/online-table";
import { deleteOnlineTable, getDocumentTable } from "@/lib/online-table";
import { Loader2, Table as TableIcon, Maximize2, Trash2, RotateCw } from "lucide-react";
interface CompactTablePreviewProps {
tableId: string;
onFullScreen: () => void;
onDelete?: () => void;
height?: number;
}
const useTableData = (tableId: string) => {
@@ -24,130 +20,55 @@ const useTableData = (tableId: string) => {
const refresh = useCallback(() => setVersion((prev) => prev + 1), []);
useEffect(() => {
let aborted = false;
let canceled = false;
setIsLoading(true);
getDocumentTable(tableId)
.then((data) => {
if (aborted) return;
setTable({ ...data, title: data.title || "未命名表格" });
if (!canceled) {
setTable({ ...data, title: data.title || "未命名表格" });
}
})
.catch((err) => {
console.error("Failed to load table:", err);
if (aborted) return;
setTable(null);
.catch((error) => {
console.error("Failed to load table:", error);
if (!canceled) {
setTable(null);
}
})
.finally(() => {
if (!aborted) {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
aborted = true;
canceled = true;
};
}, [tableId, version]);
return { table, isLoading, refresh };
};
const pickCellDisplayValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m !== undefined && cell.m !== null) return cell.m;
if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m;
if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v;
if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v;
if (cell.w !== undefined && cell.w !== null) return cell.w;
return undefined;
};
const deriveRowsFromLuckysheet = (luckysheetData: any, columns: TableColumn[]): TableRowData[] => {
const sheets = Array.isArray(luckysheetData) ? luckysheetData : [];
const sheet = sheets[0];
if (!sheet) return [];
const columnIds = columns.length > 0
? columns.map((col) => col.id)
: Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, index) => `col${index + 1}`);
const rows: TableRowData[] = [];
const dataGrid = Array.isArray(sheet.data) ? sheet.data : [];
dataGrid.forEach((rowData: any[], rowIndex: number) => {
if (!Array.isArray(rowData)) return;
const row: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellDisplayValue(rowData[colIndex]);
if (value !== undefined && value !== null && value !== "") {
row[colId] = value;
hasValue = true;
}
});
if (hasValue) rows.push(row);
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellDisplayValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
const renderCellContent = (column: TableColumn, value: any) => {
if (value === undefined || value === null || value === "") {
return "";
}
if (column.type === "select") {
const option = column.options?.find((opt) => opt.value === value);
if (option) {
return (
<span className="px-2 py-0.5 text-xs font-medium rounded-full" style={{ backgroundColor: option.color, color: "white" }}>
{value}
</span>
);
}
}
return String(value);
};
const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFullScreen, onDelete }) => {
const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({
tableId,
onFullScreen,
onDelete,
height,
}) => {
const { table, isLoading, refresh } = useTableData(tableId);
const [visibleRows, setVisibleRows] = useState(5);
const [visibleCols, setVisibleCols] = useState(5);
const [iframeVersion, setIframeVersion] = useState(0);
const [iframeLoading, setIframeLoading] = useState(true);
const schemaColumns = useMemo(
() => Array.isArray(table?.schema?.columns) ? table!.schema.columns : [],
[table],
const iframeSrc = useMemo(
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
[tableId, iframeVersion],
);
const rows: TableRowData[] = useMemo(() => {
if (!table) return [];
if (Array.isArray(table.snapshot?.rows) && table.snapshot.rows.length > 0) {
return table.snapshot.rows as TableRowData[];
}
if ((table.snapshot as { luckysheet?: unknown })?.luckysheet) {
return deriveRowsFromLuckysheet(
(table.snapshot as { luckysheet?: unknown }).luckysheet,
schemaColumns,
);
}
return [];
}, [schemaColumns, table]);
useEffect(() => {
const handleSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
setIframeVersion((value) => value + 1);
setIframeLoading(true);
}
};
const handleDeleted = (event: Event) => {
@@ -164,65 +85,6 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
};
}, [refresh, tableId]);
const hasData = rows.length > 0 && schemaColumns.length > 0;
const totalRowsAvailable = hasData ? rows.length : DEFAULT_TABLE_ROWS;
const totalColsAvailable = hasData ? schemaColumns.length : DEFAULT_TABLE_COLUMNS;
useEffect(() => {
const maxRows = Math.max(5, totalRowsAvailable);
const maxCols = Math.max(5, totalColsAvailable);
if (visibleRows > maxRows) {
setVisibleRows(maxRows);
} else if (visibleRows < 5) {
setVisibleRows(5);
}
if (visibleCols > maxCols) {
setVisibleCols(maxCols);
} else if (visibleCols < 5) {
setVisibleCols(5);
}
}, [totalRowsAvailable, totalColsAvailable, visibleRows, visibleCols]);
const columns = useMemo(() => {
if (schemaColumns.length > 0) {
return schemaColumns.slice(0, visibleCols);
}
const placeholderCount = Math.max(visibleCols, 5);
return Array.from({ length: placeholderCount }, (_, index) => ({
id: `placeholder_col_${index}`,
name: "",
type: "text" as const,
width: 100,
}));
}, [schemaColumns, visibleCols]);
const rowsToDisplay = useMemo(() => {
if (rows.length > 0) {
return rows.slice(0, visibleRows);
}
const placeholderCount = Math.max(visibleRows, 5);
return Array.from({ length: placeholderCount }, (_, index) => ({ id: `placeholder_row_${index}` }));
}, [rows, visibleRows]);
const headerCells = useMemo(() => {
const cells = [
<th key="row-actions" className="w-8" aria-label="行操作列" />,
];
columns.forEach((col) => {
cells.push(
<th
key={col.id}
style={{ width: col.width ?? 100 }}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap relative group"
>
{col.name}
</th>,
);
});
return cells;
}, [columns]);
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
@@ -240,9 +102,17 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
}
}, [onDelete, tableId]);
const handleRefresh = useCallback(() => {
setIframeVersion((value) => value + 1);
setIframeLoading(true);
refresh();
}, [refresh]);
const effectiveHeight = height ?? 520;
if (isLoading) {
return (
<div className="flex justify-center items-center h-20 bg-gray-50 border border-dashed rounded-md">
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
@@ -250,104 +120,83 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
if (!table) {
return (
<div className="flex items-center justify-center h-20 bg-red-50 border border-red-300 rounded-md text-red-700">
<Table className="h-5 w-5 mr-2" />
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
<div className="flex items-center gap-2 text-sm">
<TableIcon className="h-5 w-5" />
<span></span>
</div>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
>
<RotateCw className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className="relative w-full p-1 border border-gray-200 rounded-md transition-shadow hover:shadow-md"
className="w-full"
onDoubleClick={onFullScreen}
contentEditable={false}
onMouseDown={suppressEditorEvents}
onMouseUp={suppressEditorEvents}
onMouseMove={suppressEditorEvents}
>
<div className="flex justify-between items-center px-2 py-1">
<h3 className="text-sm font-semibold text-gray-700">{table.title}</h3>
<div className="flex items-center space-x-1">
<span className="text-[11px] text-gray-400"></span>
<button
onClick={handleDeleteTable}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="删除表格"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="进入全屏编辑"
>
<Maximize2 className="h-4 w-4" />
</button>
<div className="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center justify-between border-b border-gray-100 bg-white/90 px-4 py-2 backdrop-blur">
<div>
<p className="text-sm font-semibold text-gray-700">{table.title}</p>
<p className="text-xs text-gray-400"> · </p>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleRefresh}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
title="刷新嵌入视图"
type="button"
>
<RotateCw className="h-4 w-4" />
</button>
<button
onClick={handleDeleteTable}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
title="删除表格"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
title="进入全屏编辑"
type="button"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-100">
<tr>{headerCells}</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rowsToDisplay.map((row, index) => {
const rowId = (row as { id?: string }).id || index;
return (
<tr
key={rowId}
className="hover:bg-gray-50 relative group cursor-pointer"
onClick={onFullScreen}
>
<td className="w-8 p-0 text-center" />
{columns.map((col) => (
<td
key={col.id}
className="px-3 py-2 whitespace-nowrap text-sm text-gray-900 border-l border-gray-100 text-center align-middle min-w-[80px]"
>
{renderCellContent(col, (row as Record<string, unknown>)[col.id])}
</td>
))}
</tr>
);
})}
{rows.length > rowsToDisplay.length && (
<tr>
<td colSpan={columns.length + 1} className="px-4 py-2 text-center text-xs text-gray-500 italic">
... {rows.length - rowsToDisplay.length}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-end space-x-4 mt-2 text-xs text-gray-500">
<label className="flex items-center space-x-2">
<span></span>
<input
type="range"
min={5}
max={Math.max(5, totalRowsAvailable)}
value={visibleRows}
onChange={(event) => setVisibleRows(Number(event.target.value))}
className="h-1.5 w-28 accent-blue-500"
<div className="relative w-full bg-gray-50" style={{ height: effectiveHeight, minHeight: 320 }}>
<iframe
key={`${tableId}-${iframeVersion}`}
src={iframeSrc}
title={`online-table-${tableId}`}
className="h-full w-full border-0"
loading="lazy"
onLoad={() => setIframeLoading(false)}
allow="clipboard-read; clipboard-write"
/>
<span>{visibleRows}</span>
</label>
<label className="flex items-center space-x-2">
<span></span>
<input
type="range"
min={5}
max={Math.max(5, totalColsAvailable)}
value={visibleCols}
onChange={(event) => setVisibleCols(Number(event.target.value))}
className="h-1.5 w-28 accent-blue-500"
/>
<span>{visibleCols}</span>
</label>
{iframeLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-white/90">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
<span className="text-xs text-gray-500"> Luckysheet ...</span>
</div>
)}
</div>
</div>
</div>
);
@@ -11,6 +11,8 @@ import {
saveOnlineTable,
} from "@/lib/online-table";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { extractRowsForPreview } from "@/components/online-table/utils";
interface FullScreenTableEditorProps {
tableId: string;
@@ -20,68 +22,34 @@ interface FullScreenTableEditorProps {
// Luckysheet 容器的 ID
const LUCKY_SHEET_CONTAINER_ID = "luckysheet-editor-container";
// Luckysheet 资源路径 (相对于 public 目录)
const LUCKY_SHEET_RESOURCES = {
css: [
"/luckysheet/css/luckysheet.css",
"/luckysheet/plugins/plugins.css",
"/luckysheet/plugins/css/pluginsCss.css",
"/luckysheet/assets/iconfont/iconfont.css",
],
js: [
"/luckysheet/plugins/js/plugin.js",
"/luckysheet/luckysheet.umd.js",
],
type LuckysheetSelection =
| {
row?: [number, number];
column?: [number, number];
row_focus?: number;
column_focus?: number;
}
| undefined;
const ENABLE_SINGLE_CLICK_EDIT = true;
const isPrintableKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
if (event.key.length === 1) return true;
return event.key === "Process" || event.key === "Unidentified";
};
const extractRowsForPreview = (luckysheetData: any, columns: Array<{ id: string }>): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) return [];
const columnIds = columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
const pickValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m !== undefined && cell.m !== null) return cell.m;
if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m;
if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v;
if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v;
if (cell.w !== undefined && cell.w !== null) return cell.w;
return undefined;
};
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
const isElementInsideEditorToolbar = (element: HTMLElement | null) => {
if (!element) return false;
if (element.closest(".luckysheet-wa-editor")) {
return true;
}
return rows;
if (element.closest(".luckysheet-modal-dialog")) {
return true;
}
return false;
};
const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId, onClose }) => {
@@ -89,7 +57,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const isApplyingSnapshotRef = useRef(false);
const hasInitializedRef = useRef(false);
const lastTableIdRef = useRef<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const isLuckysheetReady = useLuckysheetLoader();
const [tableData, setTableData] = useState<DocumentTable | null>(null);
const [isTableLoading, setIsTableLoading] = useState(true);
const [tableError, setTableError] = useState<string | null>(null);
@@ -98,6 +66,16 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
const lastPointerDownInGridRef = useRef(false);
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as { __wolaiFullScreenState?: { isLoaded: boolean; tableReady: boolean } }).__wolaiFullScreenState = {
isLoaded: isLuckysheetReady,
tableReady: !!tableData,
};
}
}, [isLuckysheetReady, tableData]);
const fetchTable = useCallback((id: string) => {
setIsTableLoading(true);
@@ -127,46 +105,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
lastTableIdRef.current = tableId;
}, [fetchTable, tableId]);
// 动态加载 Luckysheet 资源
useEffect(() => {
if (window.luckysheet) {
setIsLoaded(true);
return;
if (tableData) {
setIsTableLoading(false);
}
const loadResource = (tag: "link" | "script", url: string) => {
if (document.querySelector(`${tag}[href="${url}"]`) || document.querySelector(`${tag}[src="${url}"]`)) {
return true;
}
if (tag === "link") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
return true;
} else if (tag === "script") {
return new Promise<void>((resolve) => {
const script = document.createElement("script");
script.src = url;
script.onload = () => resolve();
document.body.appendChild(script);
});
}
return false;
};
LUCKY_SHEET_RESOURCES.css.forEach((url) => loadResource("link", url));
const loadJsSequentially = async () => {
for (const url of LUCKY_SHEET_RESOURCES.js) {
await loadResource("script", url);
}
setIsLoaded(true);
};
loadJsSequentially();
}, []);
}, [tableData]);
const luckysheetSheets = useMemo(() => {
if (tableData?.snapshot?.luckysheet && Array.isArray(tableData.snapshot.luckysheet) && tableData.snapshot.luckysheet.length > 0) {
@@ -176,6 +119,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
return snapshot.luckysheet ?? [];
}, [tableData]);
const normalizedSheets = useMemo(() => {
return luckysheetSheets.map((sheet) => ({
...sheet,
celldata: Array.isArray(sheet.celldata) ? sheet.celldata : [],
config: {
...(sheet.config ?? {}),
rowlen: { ...(sheet.config?.rowlen ?? {}) },
columnlen: { ...(sheet.config?.columnlen ?? {}) },
merge: { ...(sheet.config?.merge ?? {}) },
},
}));
}, [luckysheetSheets]);
const persistSnapshot = useCallback(async (reason: "auto" | "close") => {
if (!tableData || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
return;
@@ -193,12 +149,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
};
const updated = await saveOnlineTable(tableId, {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
setTableData(updated);
setHasPendingChanges(false);
setLastSyncedAt(Date.now());
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
@@ -225,9 +180,166 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
};
}, [debouncedPersist]);
useEffect(() => {
if (!isLuckysheetReady) {
lastPointerDownInGridRef.current = false;
return;
}
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) {
return;
}
const handlePointerDownInside = () => {
lastPointerDownInGridRef.current = true;
};
const handlePointerDownDocument = (event: PointerEvent) => {
if (!(event.target instanceof Node)) {
return;
}
if (!container.contains(event.target)) {
lastPointerDownInGridRef.current = false;
}
};
container.addEventListener("pointerdown", handlePointerDownInside);
document.addEventListener("pointerdown", handlePointerDownDocument);
return () => {
container.removeEventListener("pointerdown", handlePointerDownInside);
document.removeEventListener("pointerdown", handlePointerDownDocument);
};
}, [isLuckysheetReady]);
const focusLuckysheetEditor = useCallback(() => {
requestAnimationFrame(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && typeof editor.focus === "function") {
editor.focus();
}
});
}, []);
const shouldAutoFocusEditor = useCallback(
(target: EventTarget | null) => {
if (!isLuckysheetReady || !tableData) {
return false;
}
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) {
return false;
}
const editor = document.getElementById("luckysheet-rich-text-editor");
if (!editor || document.activeElement === editor) {
return false;
}
const targetElement = target instanceof HTMLElement ? target : null;
if (isElementInsideEditorToolbar(targetElement)) {
return false;
}
const activeElement = document.activeElement as HTMLElement | null;
if (isElementInsideEditorToolbar(activeElement)) {
return false;
}
if (targetElement && container.contains(targetElement)) {
return true;
}
if (activeElement && container.contains(activeElement)) {
return true;
}
return lastPointerDownInGridRef.current;
},
[isLuckysheetReady, tableData],
);
const ensureInlineEditor = useCallback(
(target: EventTarget | null) => {
if (!shouldAutoFocusEditor(target)) {
return false;
}
if (isApplyingSnapshotRef.current) {
return false;
}
if (!window.luckysheet || typeof window.luckysheet.enterEditMode !== "function") {
return false;
}
window.luckysheet.enterEditMode();
focusLuckysheetEditor();
return true;
},
[focusLuckysheetEditor, shouldAutoFocusEditor],
);
useEffect(() => {
if (!isLuckysheetReady) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isPrintableKey(event)) {
return;
}
ensureInlineEditor(event.target);
};
const handleCompositionStart = (event: CompositionEvent) => {
ensureInlineEditor(event.target);
};
window.addEventListener("keydown", handleKeydown, true);
window.addEventListener("compositionstart", handleCompositionStart, true);
return () => {
window.removeEventListener("keydown", handleKeydown, true);
window.removeEventListener("compositionstart", handleCompositionStart, true);
};
}, [ensureInlineEditor, isLuckysheetReady]);
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
if (!Array.isArray(range) || range.length !== 1) {
return false;
}
const target = range[0];
if (!target) {
return false;
}
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
if (!rowRange || !columnRange) {
return false;
}
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
}, []);
const tryEnterSingleClickEdit = useCallback(
(range: LuckysheetSelection[] | undefined) => {
if (!ENABLE_SINGLE_CLICK_EDIT) {
return;
}
if (isApplyingSnapshotRef.current) {
return;
}
if (typeof window === "undefined") {
return;
}
const luckysheetInstance = window.luckysheet;
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
return;
}
if (!isSingleCellSelection(range)) {
return;
}
setTimeout(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && document.activeElement === editor) {
return;
}
luckysheetInstance.enterEditMode();
focusLuckysheetEditor();
}, 0);
},
[focusLuckysheetEditor, isSingleCellSelection],
);
// Luckysheet 初始化和清理
useEffect(() => {
if (!isLoaded || !tableData || !containerRef.current || !window.luckysheet) {
if (!isLuckysheetReady || !tableData || !containerRef.current || !window.luckysheet) {
return;
}
@@ -243,6 +355,8 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
containerRef.current.innerHTML = "";
}
const gridKey = tableData?.grid_key ?? tableId;
const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : "";
const options = {
container: LUCKY_SHEET_CONTAINER_ID,
title: tableData.title ?? tableId,
@@ -254,17 +368,64 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
allowEdit: true,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: luckysheetSheets,
data: normalizedSheets,
allowUpdate: false,
gridKey,
loadUrl,
uploadImage: async (file: File) => {
const formData = new FormData();
formData.append("image", file);
const response = await fetch("/api/luckysheet/upload-image", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.url) {
throw new Error(payload?.msg ?? "上传图片失败");
}
return payload.url as string;
},
imageUrlHandle: (url: string) => url,
hook: {
workbookCreateAfter: () => {
setIsTableLoading(false);
},
updated: () => {
if (isApplyingSnapshotRef.current) return;
setHasPendingChanges(true);
debouncedPersist();
},
cellEditBefore: () => {
focusLuckysheetEditor();
},
rangeSelect: (_sheet: unknown, selectedRange: LuckysheetSelection[] | LuckysheetSelection | undefined) => {
const normalizedRange = Array.isArray(selectedRange)
? (selectedRange as LuckysheetSelection[])
: selectedRange
? [selectedRange]
: undefined;
focusLuckysheetEditor();
tryEnterSingleClickEdit(normalizedRange);
},
rangeMoveAfter: (_oldRange: LuckysheetSelection[] | undefined, newRange: LuckysheetSelection[] | undefined) => {
focusLuckysheetEditor();
tryEnterSingleClickEdit(newRange);
},
},
};
window.luckysheet.create(options);
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data });
}
try {
window.luckysheet.create(options);
} catch (error) {
console.error("Luckysheet 初始化失败", error);
setTableError("Luckysheet 初始化失败,请重试");
isApplyingSnapshotRef.current = false;
return;
}
// 等待首帧渲染完成再开放 updated 事件
setTimeout(() => {
@@ -275,16 +436,20 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
if (window.luckysheet) {
window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID);
}
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
hasInitializedRef.current = false;
};
}, [debouncedPersist, luckysheetSheets, isLoaded, tableData, tableId]);
}, [debouncedPersist, normalizedSheets, isLuckysheetReady, tableData, tableId, focusLuckysheetEditor, tryEnterSingleClickEdit]);
const handleClose = async () => {
await persistSnapshot("close");
onClose();
};
const showLoadingOverlay = !isLoaded || isTableLoading;
const loadingMessage = !isLoaded ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const showLoadingOverlay = !isLuckysheetReady || isTableLoading;
const loadingMessage = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const statusText = saveError
? saveError
@@ -325,7 +490,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
id={LUCKY_SHEET_CONTAINER_ID}
ref={containerRef}
className="flex-grow w-full h-full"
style={{ display: isLoaded && !isTableLoading && !tableError ? "block" : "none" }}
style={{ display: isLuckysheetReady && !isTableLoading && !tableError ? "block" : "none" }}
/>
{showLoadingOverlay && (
@@ -0,0 +1,217 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-react";
import type { DocumentTable } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
DEFAULT_TABLE_ROWS,
DEFAULT_TABLE_SCHEMA,
createDefaultTableSnapshot,
getDocumentTable,
saveOnlineTable,
} from "@/lib/online-table";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
}
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let canceled = false;
setIsLoading(true);
setError(null);
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
setTable(data);
}
})
.catch((err) => {
console.error("加载表格失败", err);
if (!canceled) {
setTable(null);
setError("无法加载表格数据");
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
canceled = true;
};
}, [tableId, reloadVersion]);
const persistSnapshot = useCallback(async () => {
if (
!embed ||
!table ||
!window.luckysheet ||
typeof window.luckysheet.getluckysheetfile !== "function"
) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? [];
const rows = extractRowsForPreview(
luckysheetData,
(table.schema?.columns ?? []).map((item) => ({ id: item.id })),
).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0);
const snapshot = {
...(table.snapshot ?? {}),
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
};
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
setTable((prev) => (prev ? { ...prev, snapshot, rows } : prev));
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
setSaveError("自动保存失败");
} finally {
setIsSaving(false);
}
}, [embed, table, tableId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
};
}, [debouncedPersist]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
return;
}
if (typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
containerRef.current.innerHTML = "";
const sheets =
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
? table.snapshot.luckysheet
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
window.luckysheet.create({
container: containerId,
title: table.title ?? tableId,
lang: "zh",
showinfobar: false,
showtoolbar: false,
showsheetbar: sheets.length > 1,
showstatisticBar: false,
allowEdit: embed,
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
pointEdit: embed,
pointEditZoom: window.devicePixelRatio ?? 1,
pointEditUpdate: embed
? () => {
debouncedPersist();
}
: undefined,
hook: embed
? {
updated: () => {
debouncedPersist();
},
}
: undefined,
});
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
}, [containerId, isLuckysheetReady, table, tableId]);
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const embedContainerStyle = embed ? { minHeight: "100vh", height: "100vh" } : undefined;
return (
<div
className={embed ? "w-full bg-transparent" : "min-h-screen w-full bg-white"}
style={embedContainerStyle}
>
<div
className={embed ? "relative w-full" : "relative h-[calc(100vh-64px)] w-full"}
style={embedContainerStyle}
>
<div
id={containerId}
ref={containerRef}
className="h-full w-full"
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
/>
{overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
<span className="text-sm text-gray-500">{overlayText}</span>
</div>
)}
{error && !overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
<span className="text-sm">{error}</span>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
onClick={() => setReloadVersion((value) => value + 1)}
>
<RotateCw className="h-4 w-4" />
</button>
</div>
)}
{embed && (isSaving || saveError) && (
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
{isSaving && <span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">...</span>}
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
</div>
)}
</div>
</div>
);
};
export default HeadlessTableViewer;
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState } from "react";
const LUCKYSHEET_VERSION = "crdt-20251127";
const withVersion = (path: string) => `${path}?v=${LUCKYSHEET_VERSION}`;
export const LUCKYSHEET_RESOURCES = {
css: [
withVersion("/luckysheet/css/luckysheet.css"),
withVersion("/luckysheet/plugins/plugins.css"),
withVersion("/luckysheet/plugins/css/pluginsCss.css"),
withVersion("/luckysheet/assets/iconfont/iconfont.css"),
],
js: [
withVersion("/luckysheet/plugins/js/plugin.js"),
withVersion("/luckysheet/luckysheet.umd.js"),
],
};
let loaderPromise: Promise<void> | null = null;
const appendCssOnce = (href: string) => {
if (typeof document === "undefined") {
return;
}
const marker = `link[data-luckysheet-href="${href}"]`;
if (document.querySelector(marker)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.luckysheetHref = href;
document.head.appendChild(link);
};
const appendScriptOnce = (src: string) =>
new Promise<void>((resolve, reject) => {
if (typeof document === "undefined") {
resolve();
return;
}
const marker = `script[data-luckysheet-src="${src}"]`;
const existing = document.querySelector<HTMLScriptElement>(marker);
if (existing) {
if (existing.dataset.loaded === "1") {
resolve();
return;
}
const handleLoad = () => {
existing.dataset.loaded = "1";
resolve();
};
const handleError = () => {
reject(new Error(`加载 Luckysheet 资源失败: ${src}`));
};
existing.addEventListener("load", handleLoad, { once: true });
existing.addEventListener("error", handleError, { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.luckysheetSrc = src;
script.onload = () => {
script.dataset.loaded = "1";
resolve();
};
script.onerror = () => {
reject(new Error(`加载 Luckysheet 资源失败: ${src}`));
};
document.body.appendChild(script);
});
export const ensureLuckysheetLoaded = async () => {
if (typeof window === "undefined") {
return;
}
if (window.luckysheet) {
return;
}
if (loaderPromise) {
await loaderPromise;
return;
}
loaderPromise = (async () => {
LUCKYSHEET_RESOURCES.css.forEach((href) => appendCssOnce(href));
for (const src of LUCKYSHEET_RESOURCES.js) {
await appendScriptOnce(src);
}
})();
await loaderPromise;
};
export const useLuckysheetLoader = () => {
const [isReady, setIsReady] = useState(
typeof window !== "undefined" && typeof window.luckysheet !== "undefined",
);
useEffect(() => {
let canceled = false;
if (typeof window === "undefined") {
return;
}
if (window.luckysheet) {
setIsReady(true);
return;
}
ensureLuckysheetLoaded()
.then(() => {
if (!canceled) {
setIsReady(true);
}
})
.catch((error) => {
console.error("Luckysheet 资源加载失败", error);
});
return () => {
canceled = true;
};
}, []);
return isReady;
};
@@ -0,0 +1,61 @@
import type { TableRowData } from "@/types/online-table";
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
const pickCellValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m != null) return cell.m;
if (cell.v?.m != null) return cell.v.m;
if (cell.v?.v != null) return cell.v.v;
if (cell.v != null && typeof cell.v !== "object") return cell.v;
if (cell.w != null) return cell.w;
return undefined;
};
export const extractRowsForPreview = (
luckysheetData: any,
columns: Array<{ id: string }>,
): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) {
return [];
}
const columnIds =
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import type { Session } from "@supabase/supabase-js";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { supabaseBrowser } from "@/lib/supabase/client";
@@ -10,6 +11,34 @@ interface SupabaseProviderProps {
}
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
useEffect(() => {
const {
data: { subscription },
} = supabaseBrowser.auth.onAuthStateChange((_event, newSession) => {
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ event: _event, session: newSession }),
});
});
supabaseBrowser.auth.getSession().then(({ data }) => {
if (data.session) {
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ event: "INITIAL_SESSION", session: data.session }),
});
}
});
return () => {
subscription.unsubscribe();
};
}, []);
return (
<SessionContextProvider supabaseClient={supabaseBrowser} initialSession={session}>
{children}
+33 -25
View File
@@ -10,32 +10,40 @@ export const DEFAULT_TABLE_SCHEMA: TableSchema = {
export const DEFAULT_TABLE_ROWS = 50;
export const DEFAULT_TABLE_COLUMNS = 15;
export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSnapshot => ({
rows: [],
luckysheet: [
{
name: "Sheet1",
index: 0,
status: 1,
order: 0,
hide: 0,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
defaultRowHeight: 19,
defaultColWidth: 73,
celldata: [],
config: {
columnlen: {},
rowlen: {},
export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSnapshot => {
const frozenRowCount = schema.frozenRowCount ?? 0;
const frozenColCount = schema.frozenColCount ?? 0;
return {
rows: [],
luckysheet: [
{
name: "Sheet1",
index: 0,
status: 1,
order: 0,
hide: 0,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
defaultRowHeight: 19,
defaultColWidth: 73,
celldata: [],
config: {
columnlen: {},
rowlen: {},
},
frozen: {
type: frozenRowCount > 0 || frozenColCount > 0 ? "both" : undefined,
row_focus: frozenRowCount,
column_focus: frozenColCount,
},
scrollLeft: 0,
scrollTop: 0,
zoomRatio: 1,
showGridLines: true,
},
frozen: {},
scrollLeft: 0,
scrollTop: 0,
zoomRatio: 1,
showGridLines: true,
},
],
});
],
};
};
/**
* 在 Supabase 中创建一个新的在线表格并返回元数据。
+1
View File
@@ -35,6 +35,7 @@ export interface DocumentTableSnapshot {
export interface DocumentTable {
id: string;
document_id: string;
grid_key: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;