mirror of
https://github.com/zadam/trilium.git
synced 2026-03-09 21:50:24 +01:00
Compare commits
33 Commits
feature/to
...
renovate/k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d194f975c0 | ||
|
|
5f7ade45f4 | ||
|
|
8b36a7ab1e | ||
|
|
fd18276693 | ||
|
|
0becfc16ba | ||
|
|
d480d1f6ba | ||
|
|
f5c9a71ba0 | ||
|
|
c177a8a464 | ||
|
|
c826564c9e | ||
|
|
ccb13fa6b9 | ||
|
|
69e374138f | ||
|
|
3156b2cb59 | ||
|
|
d6217ffed4 | ||
|
|
fc90c6af9d | ||
|
|
a1118419ec | ||
|
|
8599785ee8 | ||
|
|
99ba192a44 | ||
|
|
b86d3587ac | ||
|
|
b2a0baf56a | ||
|
|
22f37817e5 | ||
|
|
6b4fe03625 | ||
|
|
f44b47ec23 | ||
|
|
8d667e838a | ||
|
|
f32385de2e | ||
|
|
90796fc4fa | ||
|
|
4960c49cb2 | ||
|
|
b112e8b56b | ||
|
|
83095130f6 | ||
|
|
d005c0ef2d | ||
|
|
c135578626 | ||
|
|
9a6e20029e | ||
|
|
39bd4ccea1 | ||
|
|
5c88b1c6b8 |
@@ -14,7 +14,7 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.20.2",
|
||||
"archiver": "7.0.1",
|
||||
|
||||
@@ -28,14 +28,20 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@preact/signals": "2.8.2",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@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",
|
||||
@@ -52,8 +58,8 @@
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.37",
|
||||
"knockout": "3.5.1",
|
||||
"katex": "0.16.38",
|
||||
"knockout": "3.5.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface SavedData {
|
||||
mime: string;
|
||||
content: string;
|
||||
position: number;
|
||||
encoding?: "base64";
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal file
120
apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx
Normal 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");
|
||||
});
|
||||
}
|
||||
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal file
194
apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
51
apps/server/docker/nginx-proxy-manager/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Nginx Proxy Manager (for testing reverse proxy setups)
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start Trilium on the host (default port 8080):
|
||||
```bash
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
2. Start Nginx Proxy Manager:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Open the NPM admin panel at **http://localhost:8081** and log in with:
|
||||
- Email: `admin@example.com`
|
||||
- Password: `changeme`
|
||||
(You'll be asked to change these on first login.)
|
||||
|
||||
4. Add a proxy host:
|
||||
- **Domain Names**: `localhost`
|
||||
- **Scheme**: `http`
|
||||
- **Forward Hostname / IP**: `host.docker.internal`
|
||||
- **Forward Port**: `8080`
|
||||
- Enable **Websockets Support** (required for Trilium sync)
|
||||
|
||||
5. Access Trilium through NPM at **http://localhost:8090**.
|
||||
|
||||
## With a subpath
|
||||
|
||||
To test Trilium behind a subpath (e.g. `/trilium/`), add a **Custom Nginx Configuration** in NPM under the **Advanced** tab of the proxy host:
|
||||
|
||||
```nginx
|
||||
location /trilium/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://host.docker.internal:8080/;
|
||||
proxy_cookie_path / /trilium/;
|
||||
proxy_read_timeout 90;
|
||||
}
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
19
apps/server/docker/nginx-proxy-manager/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
nginx-proxy-manager:
|
||||
image: "jc21/nginx-proxy-manager:latest"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Public HTTP port
|
||||
- "8090:80"
|
||||
# Admin panel
|
||||
- "8081:81"
|
||||
volumes:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
# Use host network mode so NPM can reach Trilium on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
npm_letsencrypt:
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"devDependencies": {
|
||||
"@wxt-dev/auto-icons": "1.1.1",
|
||||
"wxt": "0.20.18"
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
421
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal 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("<script>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
451
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -25,7 +25,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.37",
|
||||
"katex": "0.16.38",
|
||||
"mermaid": "11.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
68
pnpm-lock.yaml
generated
68
pnpm-lock.yaml
generated
@@ -219,8 +219,8 @@ importers:
|
||||
specifier: 2.11.8
|
||||
version: 2.11.8
|
||||
'@preact/signals':
|
||||
specifier: 2.8.1
|
||||
version: 2.8.1(preact@10.28.4)
|
||||
specifier: 2.8.2
|
||||
version: 2.8.2(preact@10.28.4)
|
||||
'@triliumnext/ckeditor5':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ckeditor5
|
||||
@@ -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)
|
||||
@@ -291,11 +309,11 @@ importers:
|
||||
specifier: 2.15.6
|
||||
version: 2.15.6
|
||||
katex:
|
||||
specifier: 0.16.37
|
||||
version: 0.16.37
|
||||
specifier: 0.16.38
|
||||
version: 0.16.38
|
||||
knockout:
|
||||
specifier: 3.5.1
|
||||
version: 3.5.1
|
||||
specifier: 3.5.2
|
||||
version: 3.5.2
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
@@ -1433,8 +1451,8 @@ importers:
|
||||
specifier: 7.1.0
|
||||
version: 7.1.0
|
||||
katex:
|
||||
specifier: 0.16.37
|
||||
version: 0.16.37
|
||||
specifier: 0.16.38
|
||||
version: 0.16.38
|
||||
mermaid:
|
||||
specifier: 11.12.3
|
||||
version: 11.12.3
|
||||
@@ -4420,11 +4438,11 @@ packages:
|
||||
'@babel/core': 7.x
|
||||
vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x
|
||||
|
||||
'@preact/signals-core@1.13.0':
|
||||
resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==}
|
||||
'@preact/signals-core@1.14.0':
|
||||
resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==}
|
||||
|
||||
'@preact/signals@2.8.1':
|
||||
resolution: {integrity: sha512-wX6U0SpcCukZTJBs5ChljvBZb3XmYzA5gd4vKHgX8wZZKaQCo2WHtmThdLx+mcVvlBa5u3XShC7ffbETJD4BiQ==}
|
||||
'@preact/signals@2.8.2':
|
||||
resolution: {integrity: sha512-gym5yoa64c+0w2kL7zRAAjY548qzWXbbuOfjsK9F1nWrEqooDwyWnih5SNdonjhQSp27zUqYh7UrxIRnkCyFCA==}
|
||||
peerDependencies:
|
||||
preact: 10.28.4
|
||||
|
||||
@@ -11288,8 +11306,8 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
katex@0.16.37:
|
||||
resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==}
|
||||
katex@0.16.38:
|
||||
resolution: {integrity: sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==}
|
||||
hasBin: true
|
||||
|
||||
kdbush@4.0.2:
|
||||
@@ -11326,8 +11344,8 @@ packages:
|
||||
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
knockout@3.5.1:
|
||||
resolution: {integrity: sha512-wRJ9I4az0QcsH7A4v4l0enUpkS++MBx0BnL/68KaLzJg7x1qmbjSlwEoCNol7KTYZ+pmtI7Eh2J0Nu6/2Z5J/Q==}
|
||||
knockout@3.5.2:
|
||||
resolution: {integrity: sha512-AcJS2PqsYspjtOAlnnVS8hAuBnHMEqRVEwdvmQTeXj/9zfjV//KHurzdYc8MtBd/Pu8bZLMGHc7x0cj8qUvKxQ==}
|
||||
|
||||
known-css-properties@0.37.0:
|
||||
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
|
||||
@@ -17512,6 +17530,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-table': 47.4.0
|
||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||
ckeditor5: 47.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-emoji@47.4.0':
|
||||
dependencies:
|
||||
@@ -17537,8 +17557,6 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.4.0
|
||||
'@ckeditor/ckeditor5-engine': 47.4.0
|
||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-essentials@47.4.0':
|
||||
dependencies:
|
||||
@@ -17696,6 +17714,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||
ckeditor5: 47.4.0
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-icons@47.4.0': {}
|
||||
|
||||
@@ -20729,11 +20749,11 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@preact/signals-core@1.13.0': {}
|
||||
'@preact/signals-core@1.14.0': {}
|
||||
|
||||
'@preact/signals@2.8.1(preact@10.28.4)':
|
||||
'@preact/signals@2.8.2(preact@10.28.4)':
|
||||
dependencies:
|
||||
'@preact/signals-core': 1.13.0
|
||||
'@preact/signals-core': 1.14.0
|
||||
preact: 10.28.4
|
||||
|
||||
'@prefresh/babel-plugin@0.5.2': {}
|
||||
@@ -29864,7 +29884,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
katex@0.16.37:
|
||||
katex@0.16.38:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
@@ -29892,7 +29912,7 @@ snapshots:
|
||||
|
||||
klona@2.0.6: {}
|
||||
|
||||
knockout@3.5.1: {}
|
||||
knockout@3.5.2: {}
|
||||
|
||||
known-css-properties@0.37.0: {}
|
||||
|
||||
@@ -30606,7 +30626,7 @@ snapshots:
|
||||
dagre-d3-es: 7.0.13
|
||||
dayjs: 1.11.19
|
||||
dompurify: 3.2.5
|
||||
katex: 0.16.37
|
||||
katex: 0.16.38
|
||||
khroma: 2.1.0
|
||||
lodash-es: 4.17.23
|
||||
marked: 16.4.2
|
||||
|
||||
Reference in New Issue
Block a user