chore: release 0.0.1
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 中创建一个新的在线表格并返回元数据。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user