feat: edit mode preference (#666)

* lock/unlock pages

* remove using isLocked column - add default page edit state preference

* * Move state management to editors (avoids flickers on edit mode switch)
* Rename variables
* Add strings to translation file
* Memoize components in page component
* Fix title editor sending update request on editable state change

* fixed errors merging main

* Fix embed view in read-only mode

* remove unused line

* sync

* fix responsiveness on mobile

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
fuscodev
2025-06-18 01:11:47 +02:00
committed by GitHub
parent 5f62448894
commit d1dc6977ab
17 changed files with 205 additions and 41 deletions

View File

@ -354,6 +354,9 @@
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Reading": "Reading"
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",

View File

@ -32,7 +32,7 @@ const schema = z.object({
export default function EmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes } = props;
const { node, selected, updateAttributes, editor } = props;
const { src, provider } = node.attrs;
const embedUrl = useMemo(() => {
@ -50,6 +50,10 @@ export default function EmbedView(props: NodeViewProps) {
});
async function onSubmit(data: { url: string }) {
if (!editor.isEditable) {
return;
}
if (provider) {
const embedProvider = getEmbedProviderById(provider);
if (embedProvider.id === "iframe") {
@ -85,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) {
</AspectRatio>
</>
) : (
<Popover width={300} position="bottom" withArrow shadow="md">
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
disabled={!editor.isEditable}
>
<Popover.Target>
<Card
radius="md"

View File

@ -42,7 +42,11 @@ export function FullEditor({
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
/>
</Container>
);
}

View File

@ -52,6 +52,7 @@ import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
interface PageEditorProps {
@ -85,6 +86,8 @@ export default function PageEditor({
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
@ -290,6 +293,17 @@ export default function PageEditor({
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && editor && editable && isSynced) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable, isSynced]);
return isCollabReady ? (
<div>
<div ref={menuContainerRef}>

View File

@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export interface TitleEditorProps {
pageId: string;
@ -44,6 +46,9 @@ export function TitleEditor({
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const [currentUser] = useAtom(currentUserAtom);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const titleEditor = useEditor({
extensions: [
@ -136,7 +141,18 @@ export function TitleEditor({
};
}, [pageId]);
function handleTitleKeyDown(event) {
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
}
}, [userPageEditMode, titleEditor, editable]);
function handleTitleKeyDown(event: any) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
const { key } = event;

View File

@ -1,24 +1,30 @@
.breadcrumbs {
display: flex;
align-items: center;
display: flex;
align-items: center;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
flex-wrap: nowrap;
a {
color: var(--mantine-color-default-color);
line-height: inherit;
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.breadcrumbDiv {
overflow: hidden;
@media (max-width: $mantine-breakpoint-sm) {
overflow: visible;
}
}

View File

@ -161,7 +161,7 @@ export default function Breadcrumb() {
};
return (
<div style={{ overflow: "hidden" }}>
<div className={classes.breadcrumbDiv}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}

View File

@ -33,6 +33,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
@ -59,6 +60,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>

View File

@ -1,15 +1,27 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media print {
display: none;
}
@media (max-width: $mantine-breakpoint-sm) {
padding-left: var(--mantine-spacing-xs);
padding-right: var(--mantine-spacing-xs);
}
@media print {
display: none;
}
}
.group {
@media (max-width: $mantine-breakpoint-sm) {
gap: var(--mantine-spacing-sm);
padding-inline: 0 !important;
}
}

View File

@ -9,10 +9,10 @@ interface Props {
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>

View File

@ -65,6 +65,7 @@ export interface IPageInput {
icon: string;
coverPhoto: string;
position: string;
isLocked: boolean;
}
export interface IExportPageParams {

View File

@ -0,0 +1,65 @@
import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export default function PageStatePref() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Default page edit mode")}</Text>
<Text size="sm" c="dimmed">
{t("Choose your preferred page edit mode. Avoid accidental edits.")}
</Text>
</div>
<PageStateSegmentedControl />
</Group>
);
}
interface PageStateSegmentedControlProps {
size?: MantineSize;
}
export function PageStateSegmentedControl({
size,
}: PageStateSegmentedControlProps) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const pageEditMode =
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
async (value: string) => {
const updatedUser = await updateUser({ pageEditMode: value });
setValue(value);
setUser(updatedUser);
},
[user, setUser],
);
useEffect(() => {
if (pageEditMode !== value) {
setValue(pageEditMode);
}
}, [pageEditMode, value]);
return (
<SegmentedControl
size={size}
value={value}
onChange={handleChange}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}

View File

@ -19,6 +19,7 @@ export interface IUser {
deactivatedAt: Date;
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
}
export interface ICurrentUser {
@ -29,5 +30,11 @@ export interface ICurrentUser {
export interface IUserSettings {
preferences: {
fullPageWidth: boolean;
pageEditMode: string;
};
}
}
export enum PageEditMode {
Read = "read",
Edit = "edit",
}

View File

@ -12,6 +12,11 @@ import {
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() {
const { t } = useTranslation();
@ -49,14 +54,14 @@ export default function Page() {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<PageHeader
<MemoizedPageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<FullEditor
<MemoizedFullEditor
key={page.id}
pageId={page.id}
title={page.title}
@ -68,7 +73,7 @@ export default function Page() {
SpaceCaslSubject.Page,
)}
/>
<HistoryModal pageId={page.id} />
<MemoizedHistoryModal pageId={page.id} />
</div>
)
);

View File

@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@ -28,6 +29,10 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageWidthPref />
<Divider my={"md"} />
<PageEditPref />
</>
);
}

View File

@ -1,5 +1,5 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
@ -13,6 +13,11 @@ export class UpdateUserDto extends PartialType(
@IsBoolean()
fullPageWidth: boolean;
@IsOptional()
@IsString()
@IsIn(['read', 'edit'])
pageEditMode: string;
@IsOptional()
@IsString()
locale: string;

View File

@ -34,6 +34,14 @@ export class UserService {
);
}
if (typeof updateUserDto.pageEditMode !== 'undefined') {
return this.userRepo.updatePreference(
userId,
'pageEditMode',
updateUserDto.pageEditMode.toLowerCase(),
);
}
if (updateUserDto.name) {
user.name = updateUserDto.name;
}