feat(client): handle editing temporarily mind map, canvas, spreadsheet & mermaid

This commit is contained in:
Elian Doran
2026-04-17 18:47:41 +03:00
parent 1b4400db03
commit fef8b6f58e
7 changed files with 71 additions and 25 deletions

View File

@@ -25,6 +25,15 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void;
export type SaveState = "saved" | "saving" | "unsaved" | "error";
const READ_ONLY_CAPABLE_TYPES: string[] = [
"text",
"code",
"mermaid",
"canvas",
"mindMap",
"spreadsheet"
];
export interface NoteContextDataMap {
toc: HeadingContext;
pdfPages: {
@@ -303,8 +312,12 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
// "readOnly" is a state valid only for text/code notes
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
if (!this.note) {
return false;
}
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
return false;
}
@@ -320,6 +333,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return true;
}
// Auto read-only based on content size is only configurable for text/code.
if (this.note.type !== "text" && this.note.type !== "code") {
return false;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {

View File

@@ -354,7 +354,7 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note
resultingType = "sqlConsole";
} else if (note.isMarkdown()) {
resultingType = "markdown";
} else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) {
} else if (type === "code" && (await noteContext?.isReadOnly())) {
resultingType = "readOnlyCode";
} else if (type === "text") {
resultingType = "editableText";

View File

@@ -1140,6 +1140,29 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
return { isReadOnly, enableEditing, temporarilyEditable };
}
/**
* Synchronous effective read-only state for widgets that honor the `#readOnly` label
* (mermaid, canvas, mind map, spreadsheet). Combines the label with the temporary
* "enable editing" toggle (driven by `readOnlyTemporarilyDisabled`) so clicking the
* read-only badge unlocks the widget.
*/
export function useEffectiveReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) {
const [ readOnlyLabel ] = useNoteLabelBoolean(note, "readOnly");
const [ tempDisabled, setTempDisabled ] = useState<boolean>(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
useEffect(() => {
setTempDisabled(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled);
}, [ note, noteContext, noteContext?.viewScope ]);
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContext?.ntxId === eventNoteContext?.ntxId) {
setTempDisabled(!!eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled);
}
});
return readOnlyLabel && !tempDisabled;
}
async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) {

View File

@@ -12,7 +12,7 @@ import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
import utils from "../../services/utils";
import { useColorScheme, useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { useColorScheme, useEditorSpacedUpdate, useEffectiveReadOnly, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { refToJQuerySelector } from "../react/react_utils";
import { TypeWidgetProps } from "./type_widget";
@@ -46,7 +46,7 @@ function buildMindElixirLangPack(): LangPack {
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const spacedUpdate = useEditorSpacedUpdate({

View File

@@ -1,7 +1,7 @@
import { Excalidraw } from "@excalidraw/excalidraw";
import { TypeWidgetProps } from "../type_widget";
import "@excalidraw/excalidraw/index.css";
import { useColorScheme, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumOption } from "../../react/hooks";
import { useCallback, useMemo, useRef } from "preact/hooks";
import { type ExcalidrawImperativeAPI, type AppState } from "@excalidraw/excalidraw/types";
import options from "../../../services/options";
@@ -18,7 +18,7 @@ window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excali
export default function Canvas({ note, noteContext }: TypeWidgetProps) {
const apiRef = useRef<ExcalidrawImperativeAPI>(null);
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const colorScheme = useColorScheme();
const [ locale ] = useTriliumOption("locale");
const persistence = useCanvasPersistence(note, noteContext, apiRef, colorScheme, isReadOnly);

View File

@@ -8,8 +8,8 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import utils, { isMobile } from "../../../services/utils";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
import Admonition from "../../react/Admonition";
import { useNoteBlob, useNoteLabel, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps } from "../code/Code";
import { useEffectiveReadOnly, useNoteBlob, useNoteLabel, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps, ReadOnlyCode } from "../code/Code";
export interface SplitEditorProps extends EditableCodeProps {
className?: string;
@@ -37,11 +37,11 @@ export interface SplitEditorProps extends EditableCodeProps {
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
export default function SplitEditor({ note, noteContext, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, extraContent, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const splitEditorOrientation = useSplitOrientation(forceOrientation);
const [ displayMode ] = useNoteLabel(note, "displayMode");
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
const readOnly = useEffectiveReadOnly(note, noteContext);
const mode = displayMode === "source" || displayMode === "split" || displayMode === "preview"
? displayMode
: readOnly ? "preview" : "split";
@@ -52,28 +52,33 @@ export default function SplitEditor({ note, error, splitOptions, previewContent,
if (mode !== "preview") editorMounted.current = true;
if (mode !== "source") previewMounted.current = true;
// While the editor isn't mounted, the preview has no source for its content — feed it from the
// blob directly. Once the editor mounts (and stays mounted), it takes over and this short-circuits.
const fallbackBlob = useNoteBlob(editorMounted.current ? null : note);
// The editor only feeds content to the preview when it's an `EditableCode`. `ReadOnlyCode`
// doesn't expose `onContentChanged`, and in preview-only mode the editor isn't mounted at all —
// in both cases we read the blob directly so the preview stays populated.
const editorPropagatesContent = editorMounted.current && !readOnly;
const fallbackBlob = useNoteBlob(editorPropagatesContent ? null : note);
const onContentChangedRef = useRef(editorProps.onContentChanged);
useEffect(() => { onContentChangedRef.current = editorProps.onContentChanged; });
useEffect(() => {
if (!editorMounted.current && fallbackBlob) {
if (!editorPropagatesContent && fallbackBlob) {
onContentChangedRef.current?.(fallbackBlob.content ?? "");
}
}, [ fallbackBlob ]);
}, [ fallbackBlob, editorPropagatesContent ]);
const editor = editorMounted.current && (
<div className="note-detail-split-editor-col">
{editorBefore}
<div className="note-detail-split-editor">
<EditableCode
note={note}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>
{readOnly
? <ReadOnlyCode note={note} noteContext={noteContext} {...editorProps} />
: <EditableCode
note={note}
noteContext={noteContext}
lineWrapping={false}
updateInterval={750} debounceUpdate
noBackgroundChange
{...editorProps}
/>}
</div>
{error && (
<Admonition type="caution" className="note-detail-error-container">

View File

@@ -24,12 +24,12 @@ import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks";
import { useColorScheme, useEffectiveReadOnly, 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");
const readOnly = useEffectiveReadOnly(props.note, props.noteContext);
// Use readOnly as key to force full remount (and data reload) when it changes.
return <SpreadsheetEditor key={String(readOnly)} {...props} readOnly={readOnly} />;