Spreadsheet experiment v0.5 (#8966)

This commit is contained in:
Elian Doran 2026-03-09 07:47:08 +02:00 committed by GitHub
commit 8b36a7ab1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1285 additions and 144 deletions

View File

@ -35,7 +35,13 @@
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.16.1",
"@univerjs/preset-sheets-core": "0.16.1",
"@univerjs/preset-sheets-data-validation": "0.16.1",
"@univerjs/preset-sheets-filter": "0.16.1",
"@univerjs/preset-sheets-find-replace": "0.16.1",
"@univerjs/preset-sheets-note": "0.16.1",
"@univerjs/preset-sheets-sort": "0.16.1",
"@univerjs/presets": "0.16.1",
"@zumer/snapdom": "2.0.2",
"autocomplete.js": "0.38.1",

View File

@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
await renderText(entity, $renderedContent, options);
} else if (type === "code") {
await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap"].includes(type)) {
} else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) {
renderImage(entity, $renderedContent, options);
} else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) {
await renderFile(entity, type, $renderedContent);

View File

@ -89,7 +89,7 @@ async function remove<T>(url: string, componentId?: string) {
return await call<T>("DELETE", url, componentId);
}
async function upload(url: string, fileToUpload: File, componentId?: string) {
async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") {
const formData = new FormData();
formData.append("upload", fileToUpload);
@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) {
"trilium-component-id": componentId
} : undefined),
data: formData,
type: "PUT",
type: method,
timeout: 60 * 60 * 1000,
contentType: false, // NEEDED, DON'T REMOVE THIS
processData: false // NEEDED, DON'T REMOVE THIS

View File

@ -40,6 +40,19 @@ export default function NoteDetail() {
const widgetRequestId = useRef(0);
const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile");
// Defer loading for tabs that haven't been active yet (e.g. on app refresh).
const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => noteContext?.isActive() ?? false);
useEffect(() => {
if (!hasTabBeenActive && noteContext?.isActive()) {
setHasTabBeenActive(true);
}
}, [ noteContext, hasTabBeenActive ]);
useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => {
if (eventNtxId === ntxId && !hasTabBeenActive) {
setHasTabBeenActive(true);
}
});
const props: TypeWidgetProps = {
note: note!,
viewScope,
@ -49,7 +62,7 @@ export default function NoteDetail() {
};
useEffect(() => {
if (!type) return;
if (!type || !hasTabBeenActive) return;
const requestId = ++widgetRequestId.current;
if (!noteTypesToRender[type]) {
@ -68,7 +81,7 @@ export default function NoteDetail() {
} else {
setActiveNoteType(type);
}
}, [ note, viewScope, type, noteTypesToRender ]);
}, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]);
// Detect note type changes.
useTriliumEvent("entitiesReloaded", async ({ loadResults }) => {
@ -247,9 +260,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
useEffect(() => {
if (isVisible) {
setCachedProps(props);
} else {
// Do nothing, keep the old props.
}
// When not visible, keep the old props to avoid re-rendering in the background.
}, [ props, isVisible ]);
const typeMapping = TYPE_MAPPINGS[type];
@ -260,7 +272,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: {
height: isFullHeight ? "100%" : ""
}}
>
{ <Element {...cachedProps} /> }
<Element {...cachedProps} />
</div>
);
}

View File

@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
return <FilePreview fullRevision={fullRevision} revisionItem={revisionItem} />;
case "canvas":
case "mindMap":
case "mermaid": {
case "mermaid":
case "spreadsheet": {
const encodedTitle = encodeURIComponent(revisionItem.title);
return <img
src={`api/revisions/${revisionItem.revisionId}/image/${encodedTitle}?${Math.random()}`}

View File

@ -143,7 +143,7 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
isFullHeight: true
},
spreadsheet: {
view: () => import("./type_widgets/Spreadsheet"),
view: () => import("./type_widgets/spreadsheet/Spreadsheet"),
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true

View File

@ -98,6 +98,7 @@ export interface SavedData {
mime: string;
content: string;
position: number;
encoding?: "base64";
}[];
}

View File

@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
const noteType = useNoteProperty(note, "type") ?? "";
const [viewType] = useNoteLabel(note, "viewType");
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType);
const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
const isContentAvailable = note.isContentAvailable();

View File

@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N
export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely();
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite)
const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite)
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <NoteAction

View File

@ -1,125 +0,0 @@
import "@univerjs/preset-sheets-core/lib/index.css";
import "./Spreadsheet.css";
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { CommandType, createUniver, FUniver, IDisposable, IWorkbookData, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import { useColorScheme, useEditorSpacedUpdate } from "../react/hooks";
import { TypeWidgetProps } from "./type_widget";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef);
return <div ref={containerRef} className="spreadsheet" />;
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
UniverPresetSheetsCoreEnUS
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
})
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>) {
const changeListener = useRef<IDisposable>(null);
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
return {
content: JSON.stringify(content)
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
},
});
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@ -0,0 +1,120 @@
import "./Spreadsheet.css";
import "@univerjs/preset-sheets-core/lib/index.css";
import "@univerjs/preset-sheets-sort/lib/index.css";
import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
import "@univerjs/preset-sheets-find-replace/lib/index.css";
import "@univerjs/preset-sheets-note/lib/index.css";
import "@univerjs/preset-sheets-filter/lib/index.css";
import "@univerjs/preset-sheets-data-validation/lib/index.css";
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation';
import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US';
import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
export default function Spreadsheet(props: TypeWidgetProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;
}
function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<FUniver>();
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
useSearchIntegration(apiRef);
// Focus the spreadsheet when the note is focused.
useTriliumEvent("focusOnDetail", () => {
const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]');
if (focusable instanceof HTMLElement) {
focusable.focus();
}
});
return <div ref={containerRef} className="spreadsheet" />;
}
function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null>, apiRef: MutableRef<FUniver | undefined>, readOnly: boolean) {
useEffect(() => {
if (!containerRef.current) return;
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(
sheetsCoreEnUS,
sheetsFindReplaceEnUS,
sheetsNoteEnUS,
UniverPresetSheetsFilterEnUS,
UniverPresetSheetsSortEnUS,
UniverPresetSheetsDataValidationEnUS,
UniverPresetSheetsConditionalFormattingEnUS,
),
},
presets: [
UniverSheetsCorePreset({
container: containerRef.current,
toolbar: !readOnly,
contextMenu: !readOnly,
formulaBar: !readOnly,
footer: readOnly ? false : undefined,
menu: {
"sheet.contextMenu.permission": { hidden: true },
"sheet-permission.operation.openPanel": { hidden: true },
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
},
}),
UniverSheetsFindReplacePreset(),
UniverSheetsNotePreset(),
UniverSheetsFilterPreset(),
UniverSheetsSortPreset(),
UniverSheetsDataValidationPreset(),
UniverSheetsConditionalFormattingPreset()
]
});
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef, readOnly ]);
}
function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
const colorScheme = useColorScheme();
// React to dark mode.
useEffect(() => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
univerAPI.toggleDarkMode(colorScheme === 'dark');
}, [ colorScheme, apiRef ]);
}
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
useTriliumEvent("findInText", () => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
// Open find/replace panel and populate the search term.
univerAPI.executeCommand("ui.operation.open-find-dialog");
});
}

View File

@ -0,0 +1,194 @@
import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets";
import { MutableRef, useEffect, useRef } from "preact/hooks";
import NoteContext from "../../../components/note_context";
import FNote from "../../../entities/fnote";
import { SavedData, useEditorSpacedUpdate } from "../../react/hooks";
interface PersistedData {
version: number;
workbook: Parameters<FUniver["createWorkbook"]>[0];
}
interface SpreadsheetViewState {
activeSheetId?: string;
cursorRow?: number;
cursorCol?: number;
scrollRow?: number;
scrollCol?: number;
}
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
const changeListener = useRef<IDisposable>(null);
const pendingContent = useRef<string | null>(null);
function saveViewState(univerAPI: FUniver): SpreadsheetViewState {
const state: SpreadsheetViewState = {};
try {
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return state;
const activeSheet = workbook.getActiveSheet();
state.activeSheetId = activeSheet?.getSheetId();
const currentCell = activeSheet?.getSelection()?.getCurrentCell();
if (currentCell) {
state.cursorRow = currentCell.actualRow;
state.cursorCol = currentCell.actualColumn;
}
const scrollState = activeSheet?.getScrollState?.();
if (scrollState) {
state.scrollRow = scrollState.sheetViewStartRow;
state.scrollCol = scrollState.sheetViewStartColumn;
}
} catch {
// Ignore errors when reading state from a workbook being disposed.
}
return state;
}
function restoreViewState(workbook: ReturnType<FUniver["createWorkbook"]>, state: SpreadsheetViewState) {
try {
if (state.activeSheetId) {
const targetSheet = workbook.getSheetBySheetId(state.activeSheetId);
if (targetSheet) {
workbook.setActiveSheet(targetSheet);
}
}
if (state.cursorRow !== undefined && state.cursorCol !== undefined) {
workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate();
}
if (state.scrollRow !== undefined && state.scrollCol !== undefined) {
workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol);
}
} catch {
// Ignore errors when restoring state (e.g. sheet no longer exists).
}
}
function applyContent(univerAPI: FUniver, newContent: string) {
const viewState = saveViewState(univerAPI);
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
try {
const parsedContent = JSON.parse(newContent) as unknown;
if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) {
const persistedData = parsedContent as PersistedData;
workbookData = persistedData.workbook;
}
} catch (e) {
console.error("Failed to parse spreadsheet content", e);
}
}
const workbook = univerAPI.createWorkbook(workbookData);
if (readOnly) {
workbook.disableSelection();
const permission = workbook.getPermission();
permission.setWorkbookEditPermission(workbook.getId(), false);
permission.setPermissionDialogVisible(false);
}
restoreViewState(workbook, viewState);
if (changeListener.current) {
changeListener.current.dispose();
}
changeListener.current = workbook.onCommandExecuted(command => {
if (command.type !== CommandType.MUTATION) return;
spacedUpdate.scheduleUpdate();
});
}
function isContainerVisible() {
const el = containerRef.current;
if (!el) return false;
return el.offsetWidth > 0 && el.offsetHeight > 0;
}
const spacedUpdate = useEditorSpacedUpdate({
noteType: "spreadsheet",
note,
noteContext,
async getData() {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return undefined;
const content = {
version: 1,
workbook: workbook.save()
};
const attachments: SavedData["attachments"] = [];
const canvasEl = containerRef.current?.querySelector<HTMLCanvasElement>("canvas[id]");
if (canvasEl) {
const dataUrl = canvasEl.toDataURL("image/png");
const base64 = dataUrl.split(",")[1];
attachments.push({
role: "image",
title: "spreadsheet-export.png",
mime: "image/png",
content: base64,
position: 0,
encoding: "base64"
});
}
return {
content: JSON.stringify(content),
attachments
};
},
onContentChange(newContent) {
const univerAPI = apiRef.current;
if (!univerAPI) return undefined;
// Defer content application if the container is hidden (zero size),
// since the spreadsheet library cannot calculate layout in that state.
if (!isContainerVisible()) {
pendingContent.current = newContent;
return;
}
pendingContent.current = null;
applyContent(univerAPI, newContent);
},
});
// Apply pending content once the container becomes visible (non-zero size).
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
if (pendingContent.current === null || !isContainerVisible()) return;
const univerAPI = apiRef.current;
if (!univerAPI) return;
const content = pendingContent.current;
pendingContent.current = null;
applyContent(univerAPI, content);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs
}, [ containerRef ]);
useEffect(() => {
return () => {
if (changeListener.current) {
changeListener.current.dispose();
changeListener.current = null;
}
};
}, []);
}

View File

@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
if (!image) {
res.set("Content-Type", "image/png");
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
} else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) {
} else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) {
return res.sendStatus(400);
}
@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mermaid-export.svg");
} else if (image.type === "mindMap") {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.type === "spreadsheet") {
renderPngAttachment(image, res, "spreadsheet-export.png");
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
res.send(svg);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
res.set("Content-Type", "image/png");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
} else {
res.sendStatus(404);
}
}
function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) {
const attachment = becca.getAttachment(req.params.attachmentId);

View File

@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
if (attachments?.length > 0) {
const existingAttachmentsByTitle = toMap(note.getAttachments(), "title");
for (const { attachmentId, role, mime, title, position, content } of attachments) {
for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) {
const decodedContent = encoding === "base64" && typeof content === "string"
? Buffer.from(content, "base64")
: content;
const existingAttachment = existingAttachmentsByTitle.get(title);
if (attachmentId || !existingAttachment) {
note.saveAttachment({ attachmentId, role, mime, title, content, position });
note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position });
} else {
existingAttachment.role = role;
existingAttachment.mime = mime;
existingAttachment.position = position;
if (content) {
existingAttachment.setContent(content, { forceSave: true });
if (decodedContent) {
existingAttachment.setContent(decodedContent, { forceSave: true });
}
}
}

View File

@ -1,4 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
import { highlightAuto } from "@triliumnext/highlightjs";
import ejs from "ejs";
import escapeHtml from "escape-html";
@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) {
result.isEmpty = true;
} else if (note.type === "webView") {
renderWebView(note, result);
} else if (note.type === "spreadsheet") {
renderSpreadsheet(result);
} else {
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
}
@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) {
}
}
function renderSpreadsheet(result: Result) {
if (typeof result.content !== "string" || !result.content?.trim()) {
result.isEmpty = true;
} else {
result.content = renderSpreadsheetToHtml(result.content);
}
}
function renderWebView(note: SNote | BNote, result: Result) {
const url = note.getLabelValue("webViewSrc");
if (!url) return;

View File

@ -15,3 +15,4 @@ export * from "./lib/dayjs.js";
export * from "./lib/notes.js";
export * from "./lib/week_utils.js";
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
export * from "./lib/spreadsheet/render_to_html.js";

View File

@ -17,6 +17,8 @@ export interface AttachmentRow {
deleteId?: string;
contentLength?: number;
content?: Buffer | string;
/** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */
encoding?: "base64";
}
export interface RevisionRow {

View File

@ -0,0 +1,421 @@
import { describe, expect, it } from "vitest";
import { renderSpreadsheetToHtml } from "./render_to_html.js";
describe("renderSpreadsheetToHtml", () => {
it("renders a basic spreadsheet with values and styles", () => {
const input = JSON.stringify({
version: 1,
workbook: {
id: "test",
sheetOrder: ["sheet1"],
name: "",
appVersion: "0.16.1",
locale: "zhCN",
styles: {
boldStyle: { bl: 1 }
},
sheets: {
sheet1: {
id: "sheet1",
name: "Sheet1",
hidden: 0,
rowCount: 1000,
columnCount: 20,
defaultColumnWidth: 88,
defaultRowHeight: 24,
mergeData: [],
cellData: {
"1": {
"1": { v: "lol", t: 1 }
},
"3": {
"0": { v: "wut", t: 1 },
"2": { s: "boldStyle", v: "Bold string", t: 1 }
}
},
rowData: {},
columnData: {},
showGridlines: 1
}
}
}
});
const html = renderSpreadsheetToHtml(input);
// Should contain a table.
expect(html).toContain("<table");
expect(html).toContain("</table>");
// Should contain cell values.
expect(html).toContain("lol");
expect(html).toContain("wut");
expect(html).toContain("Bold string");
// Bold cell should have font-weight:bold.
expect(html).toContain("font-weight:bold");
// Should not render sheet header for single sheet.
expect(html).not.toContain("<h3>");
});
it("renders multiple visible sheets with headers", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1", "s2"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Data",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "A1" } } },
rowData: {},
columnData: {}
},
s2: {
id: "s2",
name: "Summary",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "B1" } } },
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("<h3>Data</h3>");
expect(html).toContain("<h3>Summary</h3>");
expect(html).toContain("A1");
expect(html).toContain("B1");
});
it("skips hidden sheets", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1", "s2"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Visible",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "shown" } } },
rowData: {},
columnData: {}
},
s2: {
id: "s2",
name: "Hidden",
hidden: 1,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: { "0": { "0": { v: "secret" } } },
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("shown");
expect(html).not.toContain("secret");
// Single visible sheet, no header.
expect(html).not.toContain("<h3>");
});
it("handles merged cells", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }
],
cellData: {
"0": { "0": { v: "merged" } }
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain('rowspan="2"');
expect(html).toContain('colspan="2"');
expect(html).toContain("merged");
});
it("escapes HTML in cell values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": { "0": { v: "<script>alert('xss')</script>" } }
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("handles invalid JSON gracefully", () => {
const html = renderSpreadsheetToHtml("not json");
expect(html).toContain("Unable to parse");
});
it("handles empty workbook", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("Empty sheet");
});
it("renders boolean values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": { v: true, t: 3 },
"1": { v: false, t: 3 }
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("TRUE");
expect(html).toContain("FALSE");
});
it("applies inline styles for colors, alignment, and borders", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "styled",
s: {
bg: { rgb: "#FF0000" },
cl: { rgb: "#FFFFFF" },
ht: 2,
bd: {
b: { s: 1, cl: { rgb: "#000000" } }
}
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).toContain("background-color:#FF0000");
expect(html).toContain("color:#FFFFFF");
expect(html).toContain("text-align:center");
expect(html).toContain("border-bottom:");
});
it("sanitizes CSS injection in color values", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
bg: { rgb: "red;background:url(//evil.com/steal)" },
cl: { rgb: "#FFF;color:expression(alert(1))" }
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("evil.com");
expect(html).not.toContain("expression");
expect(html).toContain("transparent");
});
it("sanitizes CSS injection in font-family", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
ff: "Arial;}</style><script>alert(1)</script>"
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("<script>");
expect(html).not.toContain("</style>");
expect(html).toContain("font-family:Arial");
});
it("sanitizes CSS injection in border colors", () => {
const input = JSON.stringify({
version: 1,
workbook: {
sheetOrder: ["s1"],
styles: {},
sheets: {
s1: {
id: "s1",
name: "Sheet1",
hidden: 0,
rowCount: 10,
columnCount: 5,
mergeData: [],
cellData: {
"0": {
"0": {
v: "test",
s: {
bd: {
b: { s: 1, cl: { rgb: "#000;background:url(//evil.com)" } }
}
}
}
}
},
rowData: {},
columnData: {}
}
}
}
});
const html = renderSpreadsheetToHtml(input);
expect(html).not.toContain("evil.com");
expect(html).toContain("transparent");
});
});

View File

@ -0,0 +1,451 @@
/**
* Converts a UniversJS workbook JSON structure into a static HTML table representation.
* This is used for rendering spreadsheets in shared notes and exports.
*
* Only the subset of UniversJS types needed for rendering is defined here,
* to avoid depending on @univerjs/core.
*/
// #region UniversJS type subset
interface PersistedData {
version: number;
workbook: IWorkbookData;
}
interface IWorkbookData {
sheetOrder: string[];
styles?: Record<string, IStyleData | null>;
sheets: Record<string, IWorksheetData>;
}
interface IWorksheetData {
id: string;
name: string;
hidden?: number;
rowCount: number;
columnCount: number;
defaultColumnWidth?: number;
defaultRowHeight?: number;
mergeData?: IRange[];
cellData: CellMatrix;
rowData?: Record<number, IRowData>;
columnData?: Record<number, IColumnData>;
showGridlines?: number;
}
type CellMatrix = Record<number, Record<number, ICellData>>;
interface ICellData {
v?: string | number | boolean | null;
t?: number | null;
s?: IStyleData | string | null;
}
interface IStyleData {
bl?: number;
it?: number;
ul?: ITextDecoration;
st?: ITextDecoration;
fs?: number;
ff?: string | null;
bg?: IColorStyle | null;
cl?: IColorStyle | null;
ht?: number | null;
vt?: number | null;
bd?: IBorderData | null;
}
interface ITextDecoration {
s?: number;
}
interface IColorStyle {
rgb?: string | null;
}
interface IBorderData {
t?: IBorderStyleData | null;
r?: IBorderStyleData | null;
b?: IBorderStyleData | null;
l?: IBorderStyleData | null;
}
interface IBorderStyleData {
s?: number;
cl?: IColorStyle;
}
interface IRange {
startRow: number;
endRow: number;
startColumn: number;
endColumn: number;
}
interface IRowData {
h?: number;
hd?: number;
}
interface IColumnData {
w?: number;
hd?: number;
}
// Alignment enums (from UniversJS)
const enum HorizontalAlign {
LEFT = 1,
CENTER = 2,
RIGHT = 3
}
const enum VerticalAlign {
TOP = 1,
MIDDLE = 2,
BOTTOM = 3
}
// Border style enum
const enum BorderStyle {
THIN = 1,
MEDIUM = 6,
THICK = 9,
DASHED = 3,
DOTTED = 4
}
// #endregion
/**
* Parses the raw JSON content of a spreadsheet note and renders it as HTML.
* Returns an HTML string containing one `<table>` per visible sheet.
*/
export function renderSpreadsheetToHtml(jsonContent: string): string {
let data: PersistedData;
try {
data = JSON.parse(jsonContent);
} catch {
return "<p>Unable to parse spreadsheet data.</p>";
}
if (!data?.workbook?.sheets) {
return "<p>Empty spreadsheet.</p>";
}
const { workbook } = data;
const sheetIds = workbook.sheetOrder ?? Object.keys(workbook.sheets);
const visibleSheets = sheetIds
.map((id) => workbook.sheets[id])
.filter((s) => s && !s.hidden);
if (visibleSheets.length === 0) {
return "<p>Empty spreadsheet.</p>";
}
const parts: string[] = [];
for (const sheet of visibleSheets) {
if (visibleSheets.length > 1) {
parts.push(`<h3>${escapeHtml(sheet.name)}</h3>`);
}
parts.push(renderSheet(sheet, workbook.styles ?? {}));
}
return parts.join("\n");
}
function renderSheet(sheet: IWorksheetData, styles: Record<string, IStyleData | null>): string {
const { cellData, mergeData = [], columnData = {}, rowData = {} } = sheet;
// Determine the actual bounds (only cells with data).
const bounds = computeBounds(cellData, mergeData);
if (!bounds) {
return "<p>Empty sheet.</p>";
}
const { minRow, maxRow, minCol, maxCol } = bounds;
// Build a set of cells that are hidden by merges (non-origin cells).
const mergeMap = buildMergeMap(mergeData, minRow, maxRow, minCol, maxCol);
const lines: string[] = [];
lines.push('<table class="spreadsheet-table">');
// Colgroup for column widths.
const defaultWidth = sheet.defaultColumnWidth ?? 88;
lines.push("<colgroup>");
for (let col = minCol; col <= maxCol; col++) {
const colMeta = columnData[col];
if (colMeta?.hd) continue;
const width = isFiniteNumber(colMeta?.w) ? colMeta.w : defaultWidth;
lines.push(`<col style="width:${width}px">`);
}
lines.push("</colgroup>");
const defaultHeight = sheet.defaultRowHeight ?? 24;
for (let row = minRow; row <= maxRow; row++) {
const rowMeta = rowData[row];
if (rowMeta?.hd) continue;
const height = isFiniteNumber(rowMeta?.h) ? rowMeta.h : defaultHeight;
lines.push(`<tr style="height:${height}px">`);
for (let col = minCol; col <= maxCol; col++) {
if (columnData[col]?.hd) continue;
const mergeInfo = mergeMap.get(cellKey(row, col));
if (mergeInfo === "hidden") continue;
const cell = cellData[row]?.[col];
const cellStyle = resolveCellStyle(cell, styles);
const cssText = buildCssText(cellStyle);
const value = formatCellValue(cell);
const attrs: string[] = [];
if (cssText) attrs.push(`style="${cssText}"`);
if (mergeInfo) {
if (mergeInfo.rowSpan > 1) attrs.push(`rowspan="${mergeInfo.rowSpan}"`);
if (mergeInfo.colSpan > 1) attrs.push(`colspan="${mergeInfo.colSpan}"`);
}
lines.push(`<td${attrs.length ? " " + attrs.join(" ") : ""}>${value}</td>`);
}
lines.push("</tr>");
}
lines.push("</table>");
return lines.join("\n");
}
// #region Bounds computation
interface Bounds {
minRow: number;
maxRow: number;
minCol: number;
maxCol: number;
}
function computeBounds(cellData: CellMatrix, mergeData: IRange[]): Bounds | null {
let minRow = Infinity;
let maxRow = -Infinity;
let minCol = Infinity;
let maxCol = -Infinity;
for (const rowStr of Object.keys(cellData)) {
const row = Number(rowStr);
const cols = cellData[row];
for (const colStr of Object.keys(cols)) {
const col = Number(colStr);
if (minRow > row) minRow = row;
if (maxRow < row) maxRow = row;
if (minCol > col) minCol = col;
if (maxCol < col) maxCol = col;
}
}
// Extend bounds to cover merged ranges.
for (const range of mergeData) {
if (minRow > range.startRow) minRow = range.startRow;
if (maxRow < range.endRow) maxRow = range.endRow;
if (minCol > range.startColumn) minCol = range.startColumn;
if (maxCol < range.endColumn) maxCol = range.endColumn;
}
if (minRow > maxRow) return null;
return { minRow, maxRow, minCol, maxCol };
}
// #endregion
// #region Merge handling
interface MergeOrigin {
rowSpan: number;
colSpan: number;
}
type MergeInfo = MergeOrigin | "hidden";
function cellKey(row: number, col: number): string {
return `${row},${col}`;
}
function buildMergeMap(mergeData: IRange[], minRow: number, maxRow: number, minCol: number, maxCol: number): Map<string, MergeInfo> {
const map = new Map<string, MergeInfo>();
for (const range of mergeData) {
const startRow = Math.max(range.startRow, minRow);
const endRow = Math.min(range.endRow, maxRow);
const startCol = Math.max(range.startColumn, minCol);
const endCol = Math.min(range.endColumn, maxCol);
map.set(cellKey(range.startRow, range.startColumn), {
rowSpan: endRow - startRow + 1,
colSpan: endCol - startCol + 1
});
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r === range.startRow && c === range.startColumn) continue;
map.set(cellKey(r, c), "hidden");
}
}
}
return map;
}
// #endregion
// #region Style resolution
function resolveCellStyle(cell: ICellData | undefined, styles: Record<string, IStyleData | null>): IStyleData | null {
if (!cell?.s) return null;
if (typeof cell.s === "string") {
return styles[cell.s] ?? null;
}
return cell.s;
}
function buildCssText(style: IStyleData | null): string {
if (!style) return "";
const parts: string[] = [];
if (style.bl) parts.push("font-weight:bold");
if (style.it) parts.push("font-style:italic");
if (style.ul?.s) parts.push("text-decoration:underline");
if (style.st?.s) {
// Combine with underline if both are set.
const existing = parts.findIndex((p) => p.startsWith("text-decoration:"));
if (existing >= 0) {
parts[existing] = "text-decoration:underline line-through";
} else {
parts.push("text-decoration:line-through");
}
}
if (style.fs && isFiniteNumber(style.fs)) parts.push(`font-size:${style.fs}pt`);
if (style.ff) parts.push(`font-family:${sanitizeCssValue(style.ff)}`);
if (style.bg?.rgb) parts.push(`background-color:${sanitizeCssColor(style.bg.rgb)}`);
if (style.cl?.rgb) parts.push(`color:${sanitizeCssColor(style.cl.rgb)}`);
if (style.ht != null) {
const align = horizontalAlignToCss(style.ht);
if (align) parts.push(`text-align:${align}`);
}
if (style.vt != null) {
const valign = verticalAlignToCss(style.vt);
if (valign) parts.push(`vertical-align:${valign}`);
}
if (style.bd) {
appendBorderCss(parts, "border-top", style.bd.t);
appendBorderCss(parts, "border-right", style.bd.r);
appendBorderCss(parts, "border-bottom", style.bd.b);
appendBorderCss(parts, "border-left", style.bd.l);
}
return parts.join(";");
}
function horizontalAlignToCss(align: number): string | null {
switch (align) {
case HorizontalAlign.LEFT: return "left";
case HorizontalAlign.CENTER: return "center";
case HorizontalAlign.RIGHT: return "right";
default: return null;
}
}
function verticalAlignToCss(align: number): string | null {
switch (align) {
case VerticalAlign.TOP: return "top";
case VerticalAlign.MIDDLE: return "middle";
case VerticalAlign.BOTTOM: return "bottom";
default: return null;
}
}
function appendBorderCss(parts: string[], property: string, border: IBorderStyleData | null | undefined): void {
if (!border) return;
const width = borderStyleToWidth(border.s);
const color = sanitizeCssColor(border.cl?.rgb ?? "#000");
const style = borderStyleToCss(border.s);
parts.push(`${property}:${width} ${style} ${color}`);
}
function borderStyleToWidth(style: number | undefined): string {
switch (style) {
case BorderStyle.MEDIUM: return "2px";
case BorderStyle.THICK: return "3px";
default: return "1px";
}
}
function borderStyleToCss(style: number | undefined): string {
switch (style) {
case BorderStyle.DASHED: return "dashed";
case BorderStyle.DOTTED: return "dotted";
default: return "solid";
}
}
/** Checks that a value is a finite number (guards against stringified payloads from JSON). */
function isFiniteNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}
/**
* Sanitizes an arbitrary string for use as a CSS value by removing characters
* that could break out of a property (semicolons, braces, angle brackets, etc.).
*/
function sanitizeCssValue(value: string): string {
return value.replace(/[;<>{}\\/()'"]/g, "");
}
/**
* Validates a CSS color string. Accepts hex colors (#rgb, #rrggbb, #rrggbbaa),
* named colors (letters only), and rgb()/rgba()/hsl()/hsla() functional notation
* with safe characters. Returns "transparent" for anything that doesn't match.
*/
function sanitizeCssColor(value: string): string {
const trimmed = value.trim();
// Hex colors
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
// Named colors (letters only, reasonable length)
if (/^[a-zA-Z]{1,30}$/.test(trimmed)) return trimmed;
// Functional notation: rgb(), rgba(), hsl(), hsla() — allow digits, commas, dots, spaces, %
if (/^(?:rgb|hsl)a?\([0-9.,\s%]+\)$/.test(trimmed)) return trimmed;
return "transparent";
}
// #endregion
// #region Value formatting
function formatCellValue(cell: ICellData | undefined): string {
if (!cell || cell.v == null) return "";
if (typeof cell.v === "boolean") {
return cell.v ? "TRUE" : "FALSE";
}
return escapeHtml(String(cell.v));
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
;
}
// #endregion

32
pnpm-lock.yaml generated
View File

@ -239,9 +239,27 @@ importers:
'@triliumnext/split.js':
specifier: workspace:*
version: link:../../packages/splitjs
'@univerjs/preset-sheets-conditional-formatting':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-core':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-data-validation':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-filter':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-find-replace':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-note':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/preset-sheets-sort':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@univerjs/presets':
specifier: 0.16.1
version: 0.16.1(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(@wendellhu/redi@1.1.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
@ -17269,6 +17287,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@ -17334,8 +17354,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-watchdog': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-dev-build-tools@54.3.3(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)':
dependencies:
@ -17461,6 +17479,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-classic@47.4.0':
dependencies:
@ -17470,6 +17490,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-decoupled@47.4.0':
dependencies:
@ -17670,6 +17692,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-embed@47.4.0':
dependencies:
@ -17999,6 +18023,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-restricted-editing@47.4.0':
dependencies:
@ -18085,6 +18111,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-special-characters@47.4.0':
dependencies: