diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 3ca6a2a792..680e982678 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -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) { diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 180ca12ba6..b5f94b75fa 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -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"; diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 5b23276923..09fda7b843 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -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(!!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()) { diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index d4a3ff1841..bf1f3382b1 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -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(null); const containerRef = useRef(null); - const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); + const isReadOnly = useEffectiveReadOnly(note, noteContext); const spacedUpdate = useEditorSpacedUpdate({ diff --git a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx index 3c22af9c76..6ffe65c43f 100644 --- a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx +++ b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx @@ -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(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); diff --git a/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx index 943b5f7c02..60e874c7af 100644 --- a/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx +++ b/apps/client/src/widgets/type_widgets/helpers/SplitEditor.tsx @@ -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(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 && (
{editorBefore}
- + {readOnly + ? + : }
{error && ( diff --git a/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx index 2b8b02a696..6b84fbc8d8 100644 --- a/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx +++ b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx @@ -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 ;