mirror of
https://github.com/docmost/docmost.git
synced 2026-08-21 11:01:37 +10:00
Merge branch 'main' into confluence-importer
This commit is contained in:
@@ -20,7 +20,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
Docmost
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
<main>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +103,11 @@ export function InviteSignUpForm() {
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
visibilityToggleButtonProps={{
|
||||
"aria-label": t("Toggle password visibility"),
|
||||
"aria-hidden": false,
|
||||
tabIndex: 0,
|
||||
}}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
|
||||
@@ -54,6 +54,13 @@ export function LoginForm() {
|
||||
await signIn(data);
|
||||
}
|
||||
|
||||
function handleValidationFailure(errors: Record<string, unknown>) {
|
||||
const firstInvalidId = Object.keys(errors)[0];
|
||||
if (firstInvalidId) {
|
||||
document.getElementById(firstInvalidId)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (isDataLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -66,7 +73,7 @@ export function LoginForm() {
|
||||
<AuthLayout>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
<Title order={1} size="h2" ta="center" fw={500} mb="md">
|
||||
{t("Login")}
|
||||
</Title>
|
||||
|
||||
@@ -74,21 +81,31 @@ export function LoginForm() {
|
||||
|
||||
{!data?.enforceSso && (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<form onSubmit={form.onSubmit(onSubmit, handleValidationFailure)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
autoComplete="email"
|
||||
errorProps={{ role: "alert" }}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
autoComplete="current-password"
|
||||
errorProps={{ role: "alert" }}
|
||||
visibilityToggleButtonProps={{
|
||||
"aria-label": t("Toggle password visibility"),
|
||||
"aria-hidden": false,
|
||||
tabIndex: 0,
|
||||
}}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
||||
placeholder={t("Your new password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
visibilityToggleButtonProps={{
|
||||
"aria-label": t("Toggle password visibility"),
|
||||
"aria-hidden": false,
|
||||
tabIndex: 0,
|
||||
}}
|
||||
{...form.getInputProps("newPassword")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ export function SetupWorkspaceForm() {
|
||||
placeholder={t("Enter a strong password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
visibilityToggleButtonProps={{
|
||||
"aria-label": t("Toggle password visibility"),
|
||||
"aria-hidden": false,
|
||||
tabIndex: 0,
|
||||
}}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -48,11 +49,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
setReadOnlyCommentData(null);
|
||||
} else {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
if (!isEditorReady(editor)) return "";
|
||||
const { from, to } = editor.state.selection;
|
||||
return editor.state.doc.textBetween(from, to);
|
||||
};
|
||||
@@ -74,24 +78,28 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
|
||||
const createdComment =
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor
|
||||
.chain()
|
||||
.setComment(createdComment.id)
|
||||
.unsetCommentDecoration()
|
||||
.run();
|
||||
editor.commands.setTextSelection({
|
||||
from: editor.view.state.selection.from,
|
||||
to: editor.view.state.selection.from,
|
||||
});
|
||||
}
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.scrollIntoView()
|
||||
);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.view.dispatch(editor.state.tr.scrollIntoView());
|
||||
}
|
||||
}, 400);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CommentEditorProps {
|
||||
editable: boolean;
|
||||
placeholder?: string;
|
||||
autofocus?: boolean;
|
||||
surface?: "default" | "muted";
|
||||
}
|
||||
|
||||
const CommentEditor = forwardRef(
|
||||
@@ -32,6 +33,7 @@ const CommentEditor = forwardRef(
|
||||
editable,
|
||||
placeholder,
|
||||
autofocus,
|
||||
surface,
|
||||
}: CommentEditorProps,
|
||||
ref,
|
||||
) => {
|
||||
@@ -70,6 +72,9 @@ const CommentEditor = forwardRef(
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": placeholder || t("Comment"),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (
|
||||
@@ -111,22 +116,24 @@ const CommentEditor = forwardRef(
|
||||
// websocket on another browser). Skip for editable editors to avoid
|
||||
// resetting the cursor position on every keystroke.
|
||||
useEffect(() => {
|
||||
if (!editable && commentEditor && defaultContent) {
|
||||
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}
|
||||
}, [defaultContent, editable, commentEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (autofocus) {
|
||||
commentEditor?.commands.focus("end");
|
||||
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.focus("end");
|
||||
}
|
||||
}, 10);
|
||||
}, [commentEditor, autofocus]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearContent: () => {
|
||||
commentEditor.commands.clearContent();
|
||||
if (commentEditor && !commentEditor.isDestroyed) {
|
||||
commentEditor.commands.clearContent();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -135,6 +142,7 @@ const CommentEditor = forwardRef(
|
||||
ref={focusRef}
|
||||
className={classes.commentEditor}
|
||||
data-editable={editable || undefined}
|
||||
data-surface={surface}
|
||||
>
|
||||
<EditorContent
|
||||
editor={commentEditor}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
@@ -75,7 +76,9 @@ function CommentListItem({
|
||||
async function handleDeleteComment() {
|
||||
try {
|
||||
await deleteCommentMutation.mutateAsync(comment.id);
|
||||
editor?.commands.unsetComment(comment.id);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetComment(comment.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete comment:", error);
|
||||
}
|
||||
@@ -93,7 +96,7 @@ function CommentListItem({
|
||||
resolved: !isResolved,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -383,6 +383,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={t("Add a comment...")}
|
||||
surface="muted"
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -391,6 +392,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
aria-label={t("Send comment")}
|
||||
onClick={handleSave}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
|
||||
.commentEditor {
|
||||
|
||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 1px light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.focused {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
|
||||
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const yjsSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
||||
import { ActionIcon, Box, Menu, Text } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseView } from "@/ee/base/components/base-view";
|
||||
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import classes from "./base-embed.module.css";
|
||||
|
||||
const SIDE_GUTTER = 8;
|
||||
|
||||
// Extend the scroll viewport on both sides (toward AppShell.Main's
|
||||
// edges), but offset the grid content with padding-left = extendLeft
|
||||
// so the first cell still lines up with page-content on load.
|
||||
function applyExtension(wrapper: HTMLDivElement) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
|
||||
const main = wrapper.closest("main") as HTMLElement | null;
|
||||
const mainRect = main?.getBoundingClientRect();
|
||||
const targetLeft = (mainRect?.left ?? 0) + SIDE_GUTTER;
|
||||
const targetRight = mainRect
|
||||
? mainRect.right - SIDE_GUTTER
|
||||
: window.innerWidth - SIDE_GUTTER;
|
||||
|
||||
const extendLeft = Math.max(0, rect.left - targetLeft);
|
||||
const extendRight = Math.max(0, targetRight - rect.right);
|
||||
|
||||
wrapper.style.setProperty("--embed-extend-l", `${extendLeft}px`);
|
||||
wrapper.style.setProperty("--embed-extend-r", `${extendRight}px`);
|
||||
wrapper.style.setProperty("--embed-grid-pad-left", `${extendLeft}px`);
|
||||
// Symmetric right-side padding so the user can pan past the last
|
||||
// column into empty space.
|
||||
// This gives the table breathing room on the right when scrolled fully right.
|
||||
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
|
||||
// Inline sticky band clears whatever fixed surface sits above the editor —
|
||||
// the page header AND the fixed formatting toolbar. `--editor-pin-offset`
|
||||
// is the same offset the default ProseMirror table header-pin uses
|
||||
// (published by pinOffsetWatcher); fall back to the page-header height.
|
||||
// Standalone leaves --sticky-band-top unset (resolves to the rule default
|
||||
// of 0).
|
||||
wrapper.style.setProperty(
|
||||
"--sticky-band-top",
|
||||
"var(--editor-pin-offset, var(--page-header-height))",
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageId = node.attrs.pageId as string | null;
|
||||
const pendingKey = node.attrs.pendingKey as string | null;
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// Suppress the query while the slash command awaits the server-assigned
|
||||
// pageId; useBaseQuery would otherwise fire with an empty key.
|
||||
const { data: base, isLoading, isError } = useBaseQuery(
|
||||
pendingKey ? "" : pageId ?? "",
|
||||
);
|
||||
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => applyExtension(wrapper);
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
// Sidebar collapse changes <main>'s left/width without resizing
|
||||
// the wrapper itself, so observe <main> too.
|
||||
const main = wrapper.closest("main");
|
||||
if (main) ro.observe(main);
|
||||
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [isLoading, isError, pageId]);
|
||||
|
||||
// Keep --editor-pin-offset published while the embed is mounted, so the
|
||||
// sticky column header clears the fixed toolbar even when this document
|
||||
// has no default ProseMirror table holding the watcher open.
|
||||
useEffect(() => {
|
||||
pinOffsetWatcher.acquire();
|
||||
return () => pinOffsetWatcher.release();
|
||||
}, []);
|
||||
|
||||
// Error/invalid states render a compact message, not a tall reserved box.
|
||||
// The 200px min-height (which avoids a layout jump when the real table
|
||||
// mounts) is reserved only for the skeleton/loading/table states.
|
||||
const isCompact = !pendingKey && (!pageId || isError);
|
||||
|
||||
const showControls = editor.isEditable && !pendingKey;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (pendingKey) {
|
||||
// Slash command inserted the embed and is awaiting the server's
|
||||
// assigned pageId. Match the shape the create endpoint will
|
||||
// return for an inline-embed (Title + Text 1 + Text 2, one
|
||||
// empty row — see BaseService.create's `defaults`) so the swap
|
||||
// to the real table doesn't visibly collapse a large fake table
|
||||
// down to a small empty one.
|
||||
content = <BaseTableSkeleton rows={1} columns={3} />;
|
||||
} else if (!pageId) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="red">Invalid base embed (missing page id)</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
content = (
|
||||
<Box p="md">
|
||||
<Text c="dimmed">Loading...</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (isError) {
|
||||
content = (
|
||||
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
|
||||
<Text c="dimmed">You don't have access to this base.</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<BaseView
|
||||
pageId={pageId}
|
||||
embedded
|
||||
editable={hasBases && editor.isEditable && (base?.permissions?.canEdit ?? false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.handleGutter}
|
||||
data-menu-open={menuOpen ? "true" : "false"}
|
||||
>
|
||||
{showControls && (
|
||||
<div
|
||||
className={classes.controls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Menu position="bottom-end" withinPortal onChange={setMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="sm"
|
||||
aria-label={t("Base options")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
<div data-drag-preview hidden className={classes.dragPreview}>
|
||||
<IconTable size={16} />
|
||||
<span>{page?.title?.trim() || "Untitled base"}</span>
|
||||
</div>
|
||||
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
|
||||
{content}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.handleGutter {
|
||||
position: relative;
|
||||
margin-left: -1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.controls::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleGutter:hover .controls,
|
||||
.handleGutter[data-menu-open="true"] .controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.controls {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.handleGutter {
|
||||
margin-left: -1rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dragPreview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dragPreview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dragPreview svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dragPreview span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
function findBaseEmbedPlaceholderPos(
|
||||
editor: Editor,
|
||||
pendingKey: string,
|
||||
): number | null {
|
||||
let foundPos: number | null = null;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "base" && node.attrs.pendingKey === pendingKey) {
|
||||
foundPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return foundPos;
|
||||
}
|
||||
|
||||
export async function insertBaseEmbedBlock(
|
||||
editor: Editor,
|
||||
opts: { template?: "kanban"; range?: Range } = {},
|
||||
): Promise<void> {
|
||||
// @ts-ignore
|
||||
const parentPageId = editor.storage?.pageId as string | undefined;
|
||||
if (!parentPageId) return;
|
||||
|
||||
const pendingKey = uuid7();
|
||||
|
||||
const chain = editor.chain().focus();
|
||||
if (opts.range) chain.deleteRange(opts.range);
|
||||
chain.insertBaseEmbed({ pageId: null, pendingKey }).run();
|
||||
|
||||
try {
|
||||
const res = await api.post<{ id: string }>("/bases/create", {
|
||||
parentPageId,
|
||||
...(opts.template ? { template: opts.template } : {}),
|
||||
});
|
||||
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos === null) return;
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
pageId: res.data.id,
|
||||
pendingKey: null,
|
||||
});
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
} catch {
|
||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||
if (pos !== null) {
|
||||
editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
const node = tr.doc.nodeAt(pos);
|
||||
if (node) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
notifications.show({ message: "Failed to create base", color: "red" });
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
@@ -38,9 +38,11 @@ export interface BubbleMenuItem {
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
editor: Editor | null;
|
||||
templateMode?: boolean;
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { templateMode = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
@@ -224,7 +226,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
aria-label={t(item.name)}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: "none" }}
|
||||
onClick={item.command}
|
||||
onClick={() => isEditorReady(props.editor) && item.command()}
|
||||
>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
@@ -232,8 +234,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
))}
|
||||
</ActionIcon.Group>
|
||||
|
||||
<LinkSelector />
|
||||
|
||||
<ColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
@@ -246,18 +246,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={commentItem.command}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<LinkSelector />
|
||||
|
||||
{!templateMode && (
|
||||
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={() => isEditorReady(props.editor) && commentItem.command()}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
@@ -253,6 +254,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => {
|
||||
const applyTextColor = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
@@ -316,6 +318,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
|
||||
const applyHighlight = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
@@ -386,8 +389,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
data-color-grid="remove"
|
||||
className={classes.removeColor}
|
||||
onClick={() => {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
|
||||
interface NodeSelectorProps {
|
||||
@@ -193,7 +194,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
justify="left"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
item.command();
|
||||
if (isEditorReady(editor)) item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type ReadonlyBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
@@ -29,6 +30,10 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isInteractingRef.current) return;
|
||||
if (!isEditorReady(editor)) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const pmSelection = editor.state.selection;
|
||||
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
|
||||
@@ -97,7 +102,7 @@ export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
}, [showReadOnlyCommentPopup]);
|
||||
|
||||
const handleCommentClick = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const view = editor.view;
|
||||
const ystate = ySyncPluginKey.getState(view.state);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Menu, Button, Tooltip, rem } from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
interface TextAlignmentProps {
|
||||
editor: Editor | null;
|
||||
@@ -117,7 +118,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
activeItem.name === item.name ? <IconCheck size={16} /> : null
|
||||
}
|
||||
onClick={() => {
|
||||
item.command();
|
||||
if (isEditorReady(editor)) item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const nodesWithMenus = [
|
||||
"callout",
|
||||
|
||||
@@ -111,8 +111,9 @@ async function reuploadPastedAttachments(
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const cleanSrc = src.split("?")[0];
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
node.attrs.name || cleanSrc.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
@@ -182,6 +183,7 @@ async function reuploadPastedAttachments(
|
||||
);
|
||||
|
||||
if (reuploadResults.size === 0) return;
|
||||
if (editor.isDestroyed) return;
|
||||
|
||||
editor.chain().command(({ tr }) => {
|
||||
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlt } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ALT_MAX_LENGTH = 300;
|
||||
|
||||
function sanitizeAlt(value: string): string {
|
||||
return value
|
||||
.replace(/[\\\[\]!]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
type UseAltTextControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentAlt: string;
|
||||
};
|
||||
|
||||
export function useAltTextControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentAlt,
|
||||
}: UseAltTextControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentAlt || "");
|
||||
setShowInput(true);
|
||||
}, [currentAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
@@ -66,6 +67,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
||||
src: drawioAttr?.src || null,
|
||||
attachmentId: drawioAttr?.attachmentId || null,
|
||||
alt: drawioAttr?.alt || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -140,6 +142,16 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const {
|
||||
button: altTextButton,
|
||||
panel: altTextPanel,
|
||||
isEditing: isEditingAlt,
|
||||
} = useAltTextControl({
|
||||
editor,
|
||||
nodeName: "drawio",
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
const saveData = useCallback(async (svgXml: string) => {
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
@@ -266,7 +278,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
@@ -309,6 +324,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
@@ -342,7 +361,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BaseBubbleMenu>
|
||||
|
||||
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
|
||||
|
||||
@@ -198,7 +198,11 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label={t("Edit diagram")}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -131,7 +131,11 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label={t("Edit embed")}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ function EmojiList({
|
||||
const [cats, setCats] = useState<EmojiCategory[]>([]);
|
||||
const [activeCat, setActiveCat] = useState("");
|
||||
const [focusZone, setFocusZone] = useState<"grid" | "tabs">("grid");
|
||||
const [announce, setAnnounce] = useState("");
|
||||
const listViewport = useRef<HTMLDivElement>(null);
|
||||
const gridViewport = useRef<HTMLDivElement>(null);
|
||||
const catBar = useRef<HTMLDivElement>(null);
|
||||
const userInteractedRef = useRef(false);
|
||||
|
||||
const searching = query.length > 0;
|
||||
const browseLoading = !searching && cats.length === 0;
|
||||
@@ -74,6 +76,53 @@ function EmojiList({
|
||||
vp?.querySelector<HTMLElement>(`[data-i="${idx}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [idx, searching, focusZone]);
|
||||
|
||||
// Announce picker open and selection changes via a live region. Focus
|
||||
// stays in the editor, so without this the screen reader has no way to
|
||||
// know the picker exists or that arrow keys are changing the selection.
|
||||
// The setTimeout defers the open message past the initial render so the
|
||||
// live region is in the DOM before its content changes (screen readers
|
||||
// ignore content that's present at mount time).
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setAnnounce(
|
||||
t("Emoji picker open. Use arrow keys to navigate, Enter to select."),
|
||||
);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip data-driven updates (idx reset, async cat load); only announce
|
||||
// selection changes that come from real user navigation.
|
||||
if (!userInteractedRef.current) return;
|
||||
|
||||
if (focusZone === "tabs") {
|
||||
if (activeCat) setAnnounce(t("{{name}} category", { name: activeCat }));
|
||||
return;
|
||||
}
|
||||
if (searching) {
|
||||
const item = items[idx];
|
||||
if (item)
|
||||
setAnnounce(
|
||||
t("{{name}}, {{n}} of {{total}}", {
|
||||
name: item.id,
|
||||
n: idx + 1,
|
||||
total: items.length,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const entry = gridItems[idx];
|
||||
if (entry)
|
||||
setAnnounce(
|
||||
t("{{name}}, {{n}} of {{total}}", {
|
||||
name: entry.id,
|
||||
n: idx + 1,
|
||||
total: gridItems.length,
|
||||
}),
|
||||
);
|
||||
}, [idx, activeCat, focusZone, searching, items, gridItems, t]);
|
||||
|
||||
const pickSearchItem = useCallback(
|
||||
(i: number) => {
|
||||
const item = items[i];
|
||||
@@ -94,6 +143,13 @@ function EmojiList({
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
|
||||
e.key,
|
||||
)
|
||||
) {
|
||||
userInteractedRef.current = true;
|
||||
}
|
||||
if (searching) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(i + 1, items.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(i - 1, 0)); }
|
||||
@@ -131,6 +187,24 @@ function EmojiList({
|
||||
role="listbox"
|
||||
aria-label={t("Emoji picker")}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{announce}
|
||||
</div>
|
||||
{searching ? (
|
||||
<>
|
||||
{isLoading && <Loader m="xs" size="xs" color="blue" type="dots" />}
|
||||
@@ -171,6 +245,7 @@ function EmojiList({
|
||||
title={c.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={t("{{name}} category", { name: c.id })}
|
||||
className={clsx(classes.catTab, {
|
||||
[classes.catTabActive]: isActive,
|
||||
[classes.catTabFocused]: isFocused,
|
||||
@@ -190,6 +265,9 @@ function EmojiList({
|
||||
key={entry.id}
|
||||
data-i={i}
|
||||
title={`:${entry.id}:`}
|
||||
role="option"
|
||||
aria-selected={i === idx}
|
||||
aria-label={entry.id}
|
||||
className={clsx(classes.emojiBtn, { [classes.active]: i === idx })}
|
||||
onClick={() => pickGridItem(entry)}
|
||||
onMouseEnter={() => setIdx(i)}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* Push the bar to the bottom of the full-height editor container. */
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 8px;
|
||||
/* Match the content indent used by .byline and .ProseMirror so the bar
|
||||
lines up with the title and paragraph rather than the container edge. */
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import classes from "./empty-page-get-started.module.css";
|
||||
|
||||
type EmptyPageGetStartedProps = {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export function EmptyPageGetStarted({
|
||||
pageId,
|
||||
editable,
|
||||
}: EmptyPageGetStartedProps) {
|
||||
const { t } = useTranslation();
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const isSynced = useAtomValue(yjsSyncedAtom);
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
const convertMutation = useConvertPageToBaseMutation();
|
||||
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const sync = () => setIsEmpty(editor.isEmpty);
|
||||
sync();
|
||||
editor.on("update", sync);
|
||||
editor.on("create", sync);
|
||||
return () => {
|
||||
editor.off("update", sync);
|
||||
editor.off("create", sync);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
|
||||
|
||||
const chips = [
|
||||
{
|
||||
key: "base",
|
||||
label: t("Base"),
|
||||
icon: IconTable,
|
||||
onClick: () => convertMutation.mutate({ pageId }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
{
|
||||
key: "kanban",
|
||||
label: t("Kanban"),
|
||||
icon: IconLayoutKanban,
|
||||
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
|
||||
disabled: convertMutation.isPending,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper} contentEditable={false}>
|
||||
<span className={classes.label}>{t("Get started with")}</span>
|
||||
<div className={classes.chipRow}>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.key}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
leftSection={<chip.icon size={16} />}
|
||||
onClick={chip.onClick}
|
||||
disabled={chip.disabled}
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import ReactClearModal from "react-clear-modal";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
@@ -77,6 +78,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
||||
src: excalidrawAttr?.src || null,
|
||||
attachmentId: excalidrawAttr?.attachmentId || null,
|
||||
alt: excalidrawAttr?.alt || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -153,6 +155,16 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const {
|
||||
button: altTextButton,
|
||||
panel: altTextPanel,
|
||||
isEditing: isEditingAlt,
|
||||
} = useAltTextControl({
|
||||
editor,
|
||||
nodeName: "excalidraw",
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
@@ -291,7 +303,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
@@ -340,6 +355,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
@@ -373,7 +392,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BaseBubbleMenu>
|
||||
|
||||
<ReactClearModal
|
||||
|
||||
@@ -240,7 +240,11 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label={t("Edit drawing")}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FC } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { useToolbarState } from "./use-toolbar-state";
|
||||
import { BlockTypeGroup } from "./groups/block-type-group";
|
||||
import { InlineMarksGroup } from "./groups/inline-marks-group";
|
||||
import { ColorGroup } from "./groups/color-group";
|
||||
import { ListsGroup } from "./groups/lists-group";
|
||||
import { LinkGroup } from "./groups/link-group";
|
||||
import { AlignmentGroup } from "./groups/alignment-group";
|
||||
import { MediaGroup } from "./groups/media-group";
|
||||
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
|
||||
@@ -16,8 +16,17 @@ import { AskAiGroup } from "./groups/ask-ai-group";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import classes from "./fixed-toolbar.module.css";
|
||||
|
||||
export const FixedToolbar: FC = () => {
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
type FixedToolbarProps = {
|
||||
editor?: Editor | null;
|
||||
templateMode?: boolean;
|
||||
};
|
||||
|
||||
export const FixedToolbar: FC<FixedToolbarProps> = ({
|
||||
editor: editorProp,
|
||||
templateMode = false,
|
||||
}) => {
|
||||
const editorFromAtom = useAtomValue(pageEditorAtom);
|
||||
const editor = editorProp ?? editorFromAtom;
|
||||
const state = useToolbarState(editor);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
|
||||
@@ -48,14 +57,12 @@ export const FixedToolbar: FC = () => {
|
||||
<div className={classes.divider} />
|
||||
<ListsGroup editor={editor} state={state} />
|
||||
<div className={classes.divider} />
|
||||
<LinkGroup />
|
||||
<div className={classes.divider} />
|
||||
<AlignmentGroup editor={editor} />
|
||||
<div className={classes.divider} />
|
||||
<MediaGroup editor={editor} />
|
||||
<MediaGroup editor={editor} templateMode={templateMode} />
|
||||
<div className={classes.divider} />
|
||||
<QuickInsertsGroup editor={editor} />
|
||||
<MoreInsertsGroup editor={editor} />
|
||||
<MoreInsertsGroup editor={editor} templateMode={templateMode} />
|
||||
<div className={classes.divider} />
|
||||
<HistoryGroup editor={editor} state={state} />
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -25,11 +25,11 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ({
|
||||
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
|
||||
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
|
||||
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
|
||||
isBlockquote: ctx.editor.isActive("blockquote"),
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||
isHeading1: !!ctx.editor?.isActive("heading", { level: 1 }),
|
||||
isHeading2: !!ctx.editor?.isActive("heading", { level: 2 }),
|
||||
isHeading3: !!ctx.editor?.isActive("heading", { level: 3 }),
|
||||
isBlockquote: !!ctx.editor?.isActive("blockquote"),
|
||||
isCodeBlock: !!ctx.editor?.isActive("codeBlock"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector";
|
||||
|
||||
export const LinkGroup: FC = () => {
|
||||
return <LinkSelector />;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-act
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
templateMode?: boolean;
|
||||
}
|
||||
|
||||
type UploadFn = (
|
||||
@@ -60,7 +61,7 @@ function pickFile(
|
||||
input.click();
|
||||
}
|
||||
|
||||
export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
export const MediaGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -78,24 +79,30 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||
>
|
||||
{t("Image")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMovie size={16} />}
|
||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||
>
|
||||
{t("Video")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconMusic size={16} />}
|
||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||
>
|
||||
{t("Audio")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPhoto size={16} />}
|
||||
onClick={() => pickFile(editor, "image/*", true, uploadImageAction)}
|
||||
>
|
||||
{t("Image")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMovie size={16} />}
|
||||
onClick={() => pickFile(editor, "video/*", true, uploadVideoAction)}
|
||||
>
|
||||
{t("Video")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconMusic size={16} />}
|
||||
onClick={() => pickFile(editor, "audio/*", true, uploadAudioAction)}
|
||||
>
|
||||
{t("Audio")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconFileTypePdf size={16} />}
|
||||
onClick={() =>
|
||||
@@ -104,14 +111,16 @@ export const MediaGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
PDF
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={() =>
|
||||
pickFile(editor, "", true, uploadAttachmentAction, true)
|
||||
}
|
||||
>
|
||||
{t("File attachment")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
+49
-23
@@ -7,10 +7,12 @@ import {
|
||||
IconCaretRightFilled,
|
||||
IconChevronDown,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconRotate2,
|
||||
IconSitemap,
|
||||
IconTable,
|
||||
IconTag,
|
||||
} from "@tabler/icons-react";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
@@ -29,19 +31,21 @@ import {
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
templateMode?: boolean;
|
||||
}
|
||||
|
||||
export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const setEmbed = (provider: string) =>
|
||||
editor.chain().focus().setEmbed({ provider }).run();
|
||||
|
||||
const insertDate = () => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -91,14 +95,32 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
{t("Subpages")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().insertTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconRotate2 size={16} />}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleTransclusionSource().run()
|
||||
}
|
||||
>
|
||||
{t("Synced block")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconTable size={16} />}
|
||||
onClick={() => insertBaseEmbedBlock(editor)}
|
||||
>
|
||||
{t("Base (Inline)")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
|
||||
>
|
||||
{t("Kanban")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Diagrams")}</Menu.Label>
|
||||
@@ -115,18 +137,22 @@ export const MoreInsertsGroup: FC<Props> = ({ editor }) => {
|
||||
>
|
||||
{t("Mermaid diagram")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconDrawio size={16} />}
|
||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||
>
|
||||
Draw.io
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
Excalidraw
|
||||
</Menu.Item>
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconDrawio size={16} />}
|
||||
onClick={() => editor.chain().focus().setDrawio().run()}
|
||||
>
|
||||
Draw.io
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!templateMode && (
|
||||
<Menu.Item
|
||||
leftSection={<IconExcalidraw size={16} />}
|
||||
onClick={() => editor.chain().focus().setExcalidraw().run()}
|
||||
>
|
||||
Excalidraw
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("Embeds")}</Menu.Label>
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ToolbarState {
|
||||
// static editor (mainExtensions only, undoRedo disabled), neither is loaded
|
||||
// and editor.can().undo/redo is undefined.
|
||||
function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
|
||||
const can = editor.can() as Record<string, unknown>;
|
||||
const can = editor?.can() as Record<string, unknown>;
|
||||
const fn = can[command];
|
||||
return typeof fn === "function" ? (fn as () => boolean)() : false;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
|
||||
return useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) return null;
|
||||
if (!ctx.editor || ctx.editor.isDestroyed) return null;
|
||||
return {
|
||||
isBold: ctx.editor.isActive("bold"),
|
||||
isItalic: ctx.editor.isActive("italic"),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
@@ -41,6 +42,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||
src: imageAttrs?.src || null,
|
||||
alt: imageAttrs?.alt || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -136,6 +138,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const {
|
||||
button: altTextButton,
|
||||
panel: altTextPanel,
|
||||
isEditing: isEditingAlt,
|
||||
} = useAltTextControl({
|
||||
editor,
|
||||
nodeName: "image",
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -149,7 +161,10 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageLeft}
|
||||
@@ -188,6 +203,10 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
@@ -220,7 +239,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
||||
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
@@ -42,7 +42,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
||||
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={alt} />
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
<Group pos="relative" h="100%" w="100%">
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import clsx from "clsx";
|
||||
@@ -186,7 +186,7 @@ export const LinkEditorPanel = ({
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</AutoTooltipText>
|
||||
{page.space?.name && (
|
||||
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
|
||||
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
|
||||
const parseInternalLink = (
|
||||
@@ -313,7 +313,9 @@ export default function LinkView(props: MarkViewProps) {
|
||||
);
|
||||
|
||||
const handleRemoveLink = useCallback(() => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
if (isEditorReady(editor)) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
}
|
||||
setPopoverState("closed");
|
||||
}, [editor]);
|
||||
|
||||
@@ -345,7 +347,7 @@ export default function LinkView(props: MarkViewProps) {
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
const textNode = walker.nextNode();
|
||||
if (textNode) {
|
||||
if (textNode && isEditorReady(editor)) {
|
||||
const view = editor.view as any;
|
||||
view.domObserver.stop();
|
||||
textNode.nodeValue = val;
|
||||
|
||||
@@ -149,8 +149,13 @@ export default function MathBlockView(props: NodeViewProps) {
|
||||
></Textarea>
|
||||
|
||||
<Flex justify="flex-end" align="flex-end">
|
||||
<ActionIcon variant="light" color="red">
|
||||
<IconTrashX size={18} onClick={() => props.deleteNode()} />
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
aria-label={t("Delete equation")}
|
||||
onClick={() => props.deleteNode()}
|
||||
>
|
||||
<IconTrashX size={18} />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Stack>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
ScrollArea,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import classes from "./mention.module.css";
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
MentionSuggestionItem,
|
||||
} from "@/features/editor/components/mention/mention.type.ts";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageQuery,
|
||||
@@ -45,6 +48,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(1);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
@@ -99,7 +104,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
items = items.concat(
|
||||
suggestion.pages.map((page) => ({
|
||||
id: uuid7(),
|
||||
label: page.title || t("Untitled"),
|
||||
label: getPageTitle(page.title, page.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
@@ -182,6 +187,45 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
setSelectedIndex(1);
|
||||
}, [suggestion]);
|
||||
|
||||
const selectableCount = useMemo(
|
||||
() => renderItems.filter((item) => item.entityType !== "header").length,
|
||||
[renderItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderItems.length === 0) {
|
||||
setCountAnnouncement(t("No results"));
|
||||
return;
|
||||
}
|
||||
setCountAnnouncement(
|
||||
t("{{count}} result available", { count: selectableCount }),
|
||||
);
|
||||
}, [renderItems.length, selectableCount, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = renderItems[selectedIndex];
|
||||
if (!item || item.entityType === "header") {
|
||||
setSelectionAnnouncement("");
|
||||
return;
|
||||
}
|
||||
if (item.entityType === "user") {
|
||||
setSelectionAnnouncement(`${t("People")}: ${item.label}`);
|
||||
return;
|
||||
}
|
||||
if (item.entityType === "page") {
|
||||
if (item.id === null) {
|
||||
setSelectionAnnouncement(`${t("Create page")}: ${item.label}`);
|
||||
return;
|
||||
}
|
||||
const pageLabel = item.label || t("Untitled");
|
||||
setSelectionAnnouncement(
|
||||
item.spaceName
|
||||
? `${t("Pages")}: ${pageLabel}, ${item.spaceName}`
|
||||
: `${t("Pages")}: ${pageLabel}`,
|
||||
);
|
||||
}
|
||||
}, [selectedIndex, renderItems, t]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
@@ -235,7 +279,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
label: createdPage.title || "Untitled",
|
||||
label: getPageTitle(createdPage.title, createdPage.isBase, t),
|
||||
entityType: "page",
|
||||
entityId: createdPage.id,
|
||||
slugId: createdPage.slugId,
|
||||
@@ -269,6 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{countAnnouncement}
|
||||
</VisuallyHidden>
|
||||
<Text c="dimmed" size="sm" px="sm">
|
||||
{t("No results")}
|
||||
</Text>
|
||||
@@ -295,6 +342,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
aria-label={t("Mention suggestions")}
|
||||
aria-activedescendant={`mention-option-${selectedIndex}`}
|
||||
>
|
||||
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{countAnnouncement}
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{selectionAnnouncement}
|
||||
</VisuallyHidden>
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah={350}
|
||||
|
||||
+10
-2
@@ -17,6 +17,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -64,13 +65,13 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
replaceButtonToggle();
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (editor) {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerm("");
|
||||
}
|
||||
};
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!editor) return;
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
//TODO: check type error
|
||||
@@ -90,27 +91,32 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replace = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.replaceAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
@@ -118,6 +124,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
const handleOpenEvent = (e) => {
|
||||
setPageFindState({ isOpen: true });
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selectedText = editor.state.doc.textBetween(
|
||||
editor.state.selection.from,
|
||||
editor.state.selection.to,
|
||||
@@ -149,6 +156,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
}, [pageFindState.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
|
||||
editor.commands.resetIndex();
|
||||
goToSelection();
|
||||
|
||||
@@ -5,15 +5,19 @@ import {
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import classes from "./slash-menu.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
@@ -29,6 +33,15 @@ const CommandList = ({
|
||||
const { t } = useTranslation();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
|
||||
const hasBases = useHasFeature(Feature.BASES);
|
||||
// Title must match the "Base (Inline)" item in menu-items.ts. Without the
|
||||
// bases entitlement the item stays visible but disabled; an expired license
|
||||
// the client can't detect falls through to a handled create failure.
|
||||
const isItemDisabled = (item: SlashMenuItemType) =>
|
||||
!hasBases && item.title === "Base (Inline)";
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
return Object.values(items).flat();
|
||||
@@ -37,11 +50,11 @@ const CommandList = ({
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = flatItems[index];
|
||||
if (item) {
|
||||
if (item && !isItemDisabled(item)) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, flatItems],
|
||||
[command, flatItems, hasBases],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +92,25 @@ const CommandList = ({
|
||||
setSelectedIndex(0);
|
||||
}, [flatItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flatItems.length === 0) {
|
||||
setCountAnnouncement("");
|
||||
return;
|
||||
}
|
||||
setCountAnnouncement(
|
||||
t("{{count}} command available", { count: flatItems.length }),
|
||||
);
|
||||
}, [flatItems.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = flatItems[selectedIndex];
|
||||
if (!item) {
|
||||
setSelectionAnnouncement("");
|
||||
return;
|
||||
}
|
||||
setSelectionAnnouncement(`${t(item.title)}, ${t(item.description)}`);
|
||||
}, [selectedIndex, flatItems, t]);
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||
@@ -95,6 +127,12 @@ const CommandList = ({
|
||||
aria-label={t("Slash commands")}
|
||||
aria-activedescendant={`slash-command-option-${selectedIndex}`}
|
||||
>
|
||||
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{countAnnouncement}
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
|
||||
{selectionAnnouncement}
|
||||
</VisuallyHidden>
|
||||
<ScrollArea
|
||||
viewportRef={viewportRef}
|
||||
h={350}
|
||||
@@ -112,6 +150,7 @@ const CommandList = ({
|
||||
{categoryItems.map((item: SlashMenuItemType) => {
|
||||
flatIndex += 1;
|
||||
const itemIndex = flatIndex;
|
||||
const disabled = isItemDisabled(item);
|
||||
return (
|
||||
<UnstyledButton
|
||||
data-item-index={itemIndex}
|
||||
@@ -119,12 +158,15 @@ const CommandList = ({
|
||||
id={`slash-command-option-${itemIndex}`}
|
||||
role="option"
|
||||
aria-selected={itemIndex === selectedIndex}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
onClick={() => selectItem(itemIndex)}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||
[classes.disabledItem]: disabled,
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<Group wrap="nowrap">
|
||||
<ActionIcon variant="default" component="div" aria-hidden="true">
|
||||
<item.icon size={18} />
|
||||
</ActionIcon>
|
||||
@@ -138,6 +180,12 @@ const CommandList = ({
|
||||
{t(item.description)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{disabled && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{t("Upgrade")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconInfoCircle,
|
||||
IconLayoutKanban,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconMath,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
IconMenu4,
|
||||
IconPageBreak,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconColumns3,
|
||||
@@ -43,6 +45,7 @@ import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
@@ -55,6 +58,7 @@ import {
|
||||
VimeoIcon,
|
||||
YoutubeIcon,
|
||||
} from "@/components/icons";
|
||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@@ -357,6 +361,24 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Base (Inline)",
|
||||
description: "Insert an inline base on this page",
|
||||
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
||||
icon: IconTable,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
description: "Insert a kanban board on this page",
|
||||
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
||||
icon: IconLayoutKanban,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Toggle block",
|
||||
description: "Insert collapsible block.",
|
||||
@@ -459,7 +481,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
searchTerms: ["date", "today"],
|
||||
icon: IconCalendar,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentDate = new Date().toLocaleDateString("en-US", {
|
||||
const currentDate = new Date().toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -473,6 +495,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Time",
|
||||
description: "Insert current time",
|
||||
searchTerms: ["time", "now", "clock"],
|
||||
icon: IconClock,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
const currentTime = new Date().toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(currentTime)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
description: "Insert inline status badge.",
|
||||
@@ -766,18 +807,34 @@ export const getSuggestionItems = ({
|
||||
for (const [group, items] of Object.entries(CommandGroups)) {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (excludeItems?.has(item.title)) return false;
|
||||
const translatedTitle = i18n.t(item.title);
|
||||
const translatedDescription = i18n.t(item.description);
|
||||
return (
|
||||
fuzzyMatch(search, item.title) ||
|
||||
fuzzyMatch(search, translatedTitle) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
translatedDescription.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
item.searchTerms.some(
|
||||
(term: string) =>
|
||||
term.includes(search) ||
|
||||
i18n.t(term).toLowerCase().includes(search),
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
filteredGroups[group] = filteredItems.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const aTitle =
|
||||
a.title.toLowerCase().includes(search) ||
|
||||
i18n.t(a.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
const bTitle =
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
i18n.t(b.title).toLowerCase().includes(search)
|
||||
? 0
|
||||
: 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,3 +25,8 @@
|
||||
background: var(--mantine-color-gray-light);
|
||||
}
|
||||
}
|
||||
|
||||
.disabledItem {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TextSelection } from "@tiptap/pm/state";
|
||||
import React, { FC, useEffect, useRef, useState } from "react";
|
||||
import classes from "./table-of-contents.module.css";
|
||||
import clsx from "clsx";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Box, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TableOfContentsProps = {
|
||||
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
|
||||
(acc, item) => {
|
||||
const label = item.node.textContent;
|
||||
const level = Number(item.node.attrs.level);
|
||||
if (label.length && level <= 4) {
|
||||
if (label.length && level <= 6) {
|
||||
acc.push({
|
||||
label,
|
||||
level,
|
||||
@@ -50,6 +50,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
const headerPaddingRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleScrollToHeading = (position: number) => {
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
const { view } = props.editor;
|
||||
|
||||
const headerOffset = parseInt(
|
||||
@@ -73,16 +74,21 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
const result = recalculateLinks(props.editor?.$nodes("heading"));
|
||||
if (!props.editor || props.editor.isDestroyed) return;
|
||||
|
||||
const result = recalculateLinks(props.editor.$nodes("heading"));
|
||||
|
||||
setLinks(result.links);
|
||||
setHeadingDOMNodes(result.nodes);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// "create" repopulates once the editor view mounts after this component
|
||||
props.editor?.on("create", handleUpdate);
|
||||
props.editor?.on("update", handleUpdate);
|
||||
|
||||
return () => {
|
||||
props.editor?.off("create", handleUpdate);
|
||||
props.editor?.off("update", handleUpdate);
|
||||
};
|
||||
}, [props.editor]);
|
||||
@@ -156,9 +162,9 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{props.isShare && (
|
||||
<Text mb="md" fw={500}>
|
||||
<Title order={2} size="h6" mb="md" fw={500}>
|
||||
{t("Table of contents")}
|
||||
</Text>
|
||||
</Title>
|
||||
)}
|
||||
<div className={props.isShare ? classes.leftBorder : ""}>
|
||||
{links.map((item, idx) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCellSelection } from "@docmost/editor-ext";
|
||||
import { isCellSelection, isEditorReady } from "@docmost/editor-ext";
|
||||
import { CellChevronMenu } from "./menus/cell-chevron-menu";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
@@ -27,7 +27,9 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
tablePos,
|
||||
}: CellChevronProps) {
|
||||
const { t } = useTranslation();
|
||||
const cellDom = editor.view.nodeDOM(cellPos) as HTMLElement | null;
|
||||
const cellDom = isEditorReady(editor)
|
||||
? (editor.view.nodeDOM(cellPos) as HTMLElement | null)
|
||||
: null;
|
||||
|
||||
const { refs, floatingStyles, middlewareData } = useFloating({
|
||||
placement: "top-end",
|
||||
@@ -61,6 +63,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const current = editor.state.selection;
|
||||
|
||||
// Preserve an existing multi-cell CellSelection that already covers
|
||||
@@ -86,6 +89,7 @@ export const CellChevron = React.memo(function CellChevron({
|
||||
}, [editor, cellPos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||
import { ColumnHandleMenu } from "./menus/column-handle-menu";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface ColumnHandleProps {
|
||||
@@ -35,7 +36,9 @@ export const ColumnHandle = React.memo(function ColumnHandle({
|
||||
// an external drop reflows the doc before the plugin re-emits
|
||||
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
||||
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
||||
const lookupDom = editor.view.nodeDOM(anchorPos);
|
||||
const lookupDom = isEditorReady(editor)
|
||||
? editor.view.nodeDOM(anchorPos)
|
||||
: null;
|
||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import { buildRowOrColumnSelection, Orientation } from "../lib/select-row-column";
|
||||
|
||||
interface Args {
|
||||
@@ -19,6 +20,7 @@ export function useColumnRowMenuLifecycle({
|
||||
tablePos,
|
||||
}: Args) {
|
||||
const onOpen = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const selection = buildRowOrColumnSelection(
|
||||
editor.state,
|
||||
tableNode,
|
||||
@@ -33,6 +35,7 @@ export function useColumnRowMenuLifecycle({
|
||||
}, [editor, orientation, index, tableNode, tablePos]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.unfreezeHandles();
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
|
||||
type Scope =
|
||||
| { kind: "col"; index: number }
|
||||
@@ -15,6 +16,7 @@ export function useTableClear(
|
||||
scope: Scope,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const tableStart = tablePos + 1;
|
||||
const map = TableMap.get(tableNode);
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
import { isEditorReady, moveColumn, moveRow } from "@docmost/editor-ext";
|
||||
|
||||
export type MoveDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function useTableMoveRowColumn(
|
||||
const canMove = target >= 0 && target <= maxIndex;
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!canMove) return;
|
||||
if (!canMove || !isEditorReady(editor)) return;
|
||||
const tr = editor.state.tr;
|
||||
const moved =
|
||||
orientation === "col"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
convertArrayOfRowsToTableNode,
|
||||
convertTableNodeToArrayOfRows,
|
||||
isEditorReady,
|
||||
transpose,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -63,7 +64,7 @@ export function useTableSort({
|
||||
}, [tableNode, orientation, index]);
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
if (!canSort) return;
|
||||
if (!canSort || !isEditorReady(editor)) return;
|
||||
|
||||
const rows = convertTableNodeToArrayOfRows(tableNode);
|
||||
const axes = orientation === "col" ? rows : transpose(rows);
|
||||
|
||||
@@ -101,14 +101,14 @@ export const CellChevronMenu = React.memo(function CellChevronMenu({
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxMargin size={16} />}
|
||||
onClick={() => editor.chain().focus().mergeCells().run()}
|
||||
disabled={!editor.can().mergeCells()}
|
||||
disabled={!editor?.can().mergeCells()}
|
||||
>
|
||||
{t("Merge cells")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconSquareToggle size={16} />}
|
||||
onClick={() => editor.chain().focus().splitCell().run()}
|
||||
disabled={!editor.can().splitCell()}
|
||||
disabled={!editor?.can().splitCell()}
|
||||
>
|
||||
{t("Split cell")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
|
||||
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
|
||||
import { RowHandleMenu } from "./menus/row-handle-menu";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import classes from "./handle.module.css";
|
||||
|
||||
interface RowHandleProps {
|
||||
@@ -33,7 +34,9 @@ export const RowHandle = React.memo(function RowHandle({
|
||||
// an external drop reflows the doc before the plugin re-emits
|
||||
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
||||
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
||||
const lookupDom = editor.view.nodeDOM(anchorPos);
|
||||
const lookupDom = isEditorReady(editor)
|
||||
? editor.view.nodeDOM(anchorPos)
|
||||
: null;
|
||||
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, type JSX } from "react";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
|
||||
+1
@@ -105,6 +105,7 @@ function TransclusionReferenceBody({
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
});
|
||||
if (editor.isDestroyed) return;
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
const from = pos;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
@@ -38,6 +39,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
||||
src: videoAttrs?.src || null,
|
||||
alt: videoAttrs?.alt || "",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -112,6 +114,16 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
const {
|
||||
button: altTextButton,
|
||||
panel: altTextPanel,
|
||||
isEditing: isEditingAlt,
|
||||
} = useAltTextControl({
|
||||
editor,
|
||||
nodeName: "video",
|
||||
currentAlt: editorState?.alt || "",
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -125,7 +137,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
{isEditingAlt ? (
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
@@ -164,6 +179,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
@@ -185,7 +204,8 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
export default function VideoView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, selected } = props;
|
||||
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
||||
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
|
||||
const alignClass = useMemo(() => {
|
||||
if (align === "left") return "alignLeft";
|
||||
if (align === "right") return "alignRight";
|
||||
@@ -47,7 +47,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
preload="metadata"
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
aria-label={placeholder?.name || t("Video")}
|
||||
aria-label={alt || undefined}
|
||||
/>
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const CleanStyles = Extension.create({
|
||||
name: "cleanStyles",
|
||||
priority: 80,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("cleanStyles"),
|
||||
props: {
|
||||
transformPastedHTML(html) {
|
||||
return html.replace(/\s+style="[^"]*"/gi, "");
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
|
||||
atomNodes: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
|
||||
`[data-type=${node}] p`,
|
||||
`.node-${node} p`,
|
||||
]);
|
||||
const atomSelectors = options.atomNodes.flatMap((node) => [
|
||||
`[data-type=${node}]`,
|
||||
`.node-${node}`,
|
||||
]);
|
||||
|
||||
const selectors = [
|
||||
"li",
|
||||
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
|
||||
".tableWrapper",
|
||||
...customParagraphSelectors,
|
||||
...customSelectors,
|
||||
...atomSelectors,
|
||||
].join(", ");
|
||||
return document
|
||||
const found = document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => {
|
||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
|
||||
elem.matches(selectors)
|
||||
);
|
||||
});
|
||||
if (found && atomSelectors.length > 0) {
|
||||
const atomWrapper = found.closest(atomSelectors.join(", "));
|
||||
if (atomWrapper) return atomWrapper;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
|
||||
options: GlobalDragHandleOptions,
|
||||
): boolean {
|
||||
if (!elem) return false;
|
||||
for (const name of options.customNodes) {
|
||||
for (const name of [...options.customNodes, ...options.atomNodes]) {
|
||||
if (
|
||||
elem.getAttribute("data-type") === name ||
|
||||
elem.classList.contains(`node-${name}`)
|
||||
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
|
||||
// The drag landed on a custom-node container (transclusion etc.).
|
||||
// Walk up to the matching node so the drag moves the whole
|
||||
// container, not whatever inner element the click landed on.
|
||||
const customTypes = new Set(options.customNodes);
|
||||
const customTypes = new Set([
|
||||
...options.customNodes,
|
||||
...options.atomNodes,
|
||||
]);
|
||||
for (let d = $sel.depth; d > 0; d--) {
|
||||
if (customTypes.has($sel.node(d).type.name)) {
|
||||
selection = NodeSelection.create(
|
||||
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
const previewTemplate =
|
||||
node.querySelector<HTMLElement>("[data-drag-preview]");
|
||||
if (previewTemplate) {
|
||||
const preview = previewTemplate.cloneNode(true) as HTMLElement;
|
||||
preview.removeAttribute("hidden");
|
||||
preview.style.position = "fixed";
|
||||
preview.style.top = "0";
|
||||
preview.style.left = "-10000px";
|
||||
preview.style.pointerEvents = "none";
|
||||
document.body.appendChild(preview);
|
||||
event.dataTransfer.setDragImage(preview, 0, 0);
|
||||
document.addEventListener("dragend", () => preview.remove(), {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
}
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
|
||||
scrollThreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
atomNodes: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
atomNodes: this.options.atomNodes,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
BaseEmbed as BaseEmbedNode,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -91,6 +92,7 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -112,6 +114,7 @@ import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -230,6 +233,7 @@ export const mainExtensions = [
|
||||
TrailingNode,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
atomNodes: ["base"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
@@ -380,9 +384,15 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
BaseEmbedNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(BaseEmbedView);
|
||||
},
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
CleanStyles,
|
||||
CharacterCount.configure({
|
||||
wordCounter: (text) => countWords(text),
|
||||
}),
|
||||
@@ -416,7 +426,11 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
|
||||
"Video",
|
||||
"File attachment",
|
||||
"Draw.io (diagrams.net)",
|
||||
"Excalidraw diagram",
|
||||
"Excalidraw (Whiteboard)",
|
||||
"Audio",
|
||||
"Synced block",
|
||||
"Base (Inline)",
|
||||
"Kanban"
|
||||
]);
|
||||
|
||||
const TemplateSlashCommand = Command.configure({
|
||||
@@ -433,6 +447,7 @@ const TemplateSlashCommand = Command.configure({
|
||||
export const templateExtensions = [
|
||||
...mainExtensions.filter((ext: any) => ext !== SlashCommand),
|
||||
TemplateSlashCommand,
|
||||
UndoRedo,
|
||||
] as any;
|
||||
|
||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||
|
||||
@@ -22,10 +22,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { EmptyPageGetStarted } from "@/features/editor/components/empty-page/empty-page-get-started";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -90,6 +91,7 @@ export function FullEditor({
|
||||
fluid={fullPageWidth}
|
||||
size={!fullPageWidth && 900}
|
||||
className={classes.editor}
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{editorToolbarEnabled && editable && isEditMode && (
|
||||
<MemoizedFixedToolbar />
|
||||
@@ -113,6 +115,7 @@ export function FullEditor({
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<EmptyPageGetStarted pageId={pageId} editable={editable} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -125,7 +128,7 @@ type PageBylineProps = {
|
||||
|
||||
function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
const detailsTriggerProps = useAsideTriggerProps("details");
|
||||
|
||||
const otherContributors = (contributors ?? []).filter(
|
||||
(c) => c.id !== creator?.id,
|
||||
@@ -141,7 +144,9 @@ function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
|
||||
{creator && (
|
||||
<Popover position="bottom-start" shadow="md" width={280} withArrow>
|
||||
<Popover.Target>
|
||||
<UnstyledButton>
|
||||
<UnstyledButton
|
||||
aria-label={t("Created by {{name}}", { name: creator.name })}
|
||||
>
|
||||
<Group gap={6}>
|
||||
<CustomAvatar
|
||||
avatarUrl={creator.avatarUrl}
|
||||
@@ -203,7 +208,7 @@ function PageByline({ creator, contributors, readOnly }: PageBylineProps) {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Details")}
|
||||
onClick={() => toggleAside("details")}
|
||||
{...detailsTriggerProps}
|
||||
>
|
||||
<IconInfoCircle size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -42,6 +42,10 @@ export const useEditorScroll = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.isDestroyed) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
|
||||
if (dom) {
|
||||
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
currentPageEditModeAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
yjsSyncedAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
@@ -43,7 +45,6 @@ import {
|
||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
@@ -74,6 +75,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -88,6 +90,7 @@ export default function PageEditor({
|
||||
content,
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
@@ -107,6 +110,7 @@ export default function PageEditor({
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
@@ -144,6 +148,24 @@ export default function PageEditor({
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSynced(event.state);
|
||||
};
|
||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
@@ -168,6 +190,7 @@ export default function PageEditor({
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
onStateless: onStatelessHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
@@ -232,20 +255,15 @@ export default function PageEditor({
|
||||
editorProps: {
|
||||
scrollThreshold: 80,
|
||||
scrollMargin: 80,
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return false;
|
||||
event.preventDefault();
|
||||
return editor.view.someProp("handleKeyDown", (f) =>
|
||||
f(editor.view, event)
|
||||
);
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
@@ -322,7 +340,6 @@ export default function PageEditor({
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
content: newContent,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
@@ -363,6 +380,14 @@ export default function PageEditor({
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
useEffect(() => {
|
||||
setYjsSynced(isSynced);
|
||||
}, [isSynced, setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setYjsSynced(false);
|
||||
}, [setYjsSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||
@@ -399,6 +424,11 @@ export default function PageEditor({
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
"aria-label": t("Page content"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
@@ -429,9 +459,7 @@ export default function PageEditor({
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
@@ -440,7 +468,9 @@ export default function PageEditor({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
onClick={() => {
|
||||
if (editor && !editor.isDestroyed) editor.commands.focus("end");
|
||||
}}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
printMode?: boolean;
|
||||
/**
|
||||
* When rendering inside a public share, pass the share's id (or key). Lookups
|
||||
* for transclusion content then resolve against the share graph instead of
|
||||
@@ -28,6 +29,7 @@ export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
printMode = false,
|
||||
shareId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
@@ -48,8 +50,12 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const excludedExtensions = new Set([
|
||||
"uniqueID",
|
||||
...(printMode ? ["tableHeaderPin", "tableReadonlySort"] : []),
|
||||
]);
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
(ext) => !excludedExtensions.has(ext.name),
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -59,7 +65,7 @@ export default function ReadonlyPageEditor({
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
}, [printMode]);
|
||||
|
||||
const titleExtensions = [
|
||||
Document.extend({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.ProseMirror {
|
||||
.node-base {
|
||||
/* Suppress the default ProseMirror atom-node selection outline —
|
||||
* the embed reads as a document block, not a focused widget. */
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Page-reference pills are self-contained chips. The editor's `a` rule
|
||||
* would otherwise add a link underline + bold weight on top of the chip,
|
||||
* making it look different from a standalone base. Keep it a plain chip. */
|
||||
a.pagePill {
|
||||
border-bottom: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,13 @@
|
||||
margin: 0;
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-pink-7);
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-pink-7);
|
||||
background-color: var(--mantine-color-dark-5) !important;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
:root {
|
||||
/* Height of the fixed PageHeader at the top of every page. Used by
|
||||
* standalone base layout (paddingTop) and by sticky bands inside
|
||||
* inline base embeds (top offset). One source of truth; if the
|
||||
* page header ever changes height, edit only this. */
|
||||
--page-header-height: 45px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
@@ -286,3 +294,24 @@
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
/* The full-page base view positions its title with its own outer
|
||||
* wrapper padding (so it can align with the table below). The global
|
||||
* 3rem .ProseMirror padding-x would push the title further in than
|
||||
* the table — drop it inside the base title wrapper only. */
|
||||
.base-page-title .ProseMirror {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Definite height so the inner .tableScrollport scrolls and the sticky table
|
||||
* header pins. Flow on print, else it emits a trailing blank page. */
|
||||
.base-page-root {
|
||||
height: calc(100dvh - var(--app-shell-header-height, 45px));
|
||||
}
|
||||
|
||||
@media print {
|
||||
.base-page-root {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
.editor {
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - var(--app-shell-header-height, 45px) - 96px);
|
||||
padding: 8px 0;
|
||||
margin: 48px auto;
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./base-embed.css";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.ProseMirror .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
color: var(--mantine-color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
.ProseMirror .is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
color: var(--mantine-color-placeholder);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
|
||||
@@ -163,8 +163,13 @@
|
||||
|
||||
@media print {
|
||||
.tableWrapper.tableHeaderPinned table tr:first-child {
|
||||
position: static;
|
||||
transform: none;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.tableReadonlySortChevron {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +209,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ProseMirror table th:has(.tableReadonlySortChevron) {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.tableReadonlySortChevron:hover {
|
||||
background: light-dark(
|
||||
rgba(55, 53, 47, 0.16),
|
||||
@@ -272,4 +273,4 @@
|
||||
.prosemirror-dropcursor-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface TitleEditorProps {
|
||||
title: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
isBase?: boolean;
|
||||
}
|
||||
|
||||
export function TitleEditor({
|
||||
@@ -43,6 +44,7 @@ export function TitleEditor({
|
||||
title,
|
||||
spaceSlug,
|
||||
editable,
|
||||
isBase,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
@@ -64,7 +66,7 @@ export function TitleEditor({
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
placeholder: isBase ? t("Untitled base") : t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
@@ -87,6 +89,9 @@ export function TitleEditor({
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": t("Page title"),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
@@ -103,11 +108,17 @@ export function TitleEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
||||
navigate(pageSlug, { replace: true });
|
||||
// Canonicalize only the path slug; keep query params (?row=, ?view=
|
||||
// deep links) and the hash anchor intact.
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
|
||||
navigate(
|
||||
{
|
||||
pathname: pageSlug,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [title]);
|
||||
|
||||
const saveTitle = useCallback(() => {
|
||||
@@ -149,7 +160,11 @@ export function TitleEditor({
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleEditor && title !== titleEditor.getText()) {
|
||||
if (
|
||||
titleEditor &&
|
||||
!titleEditor.isDestroyed &&
|
||||
title !== titleEditor.getText()
|
||||
) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconStar, IconStarFilled } from "@tabler/icons-react";
|
||||
import {
|
||||
useFavoriteIds,
|
||||
@@ -14,6 +15,8 @@ type StarButtonProps = {
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
templateId?: string;
|
||||
/** Name of the item being favorited, used to make the button's accessible name descriptive. */
|
||||
name?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
@@ -25,7 +28,7 @@ function getEntityId(props: StarButtonProps): string | undefined {
|
||||
}
|
||||
|
||||
export default function StarButton(props: StarButtonProps) {
|
||||
const { type, size = 18 } = props;
|
||||
const { type, name, size = 18 } = props;
|
||||
const { t } = useTranslation();
|
||||
const favoriteIds = useFavoriteIds(type);
|
||||
const addMutation = useAddFavoriteMutation();
|
||||
@@ -47,22 +50,46 @@ export default function StarButton(props: StarButtonProps) {
|
||||
};
|
||||
|
||||
if (isFavorited) {
|
||||
removeMutation.mutate(params);
|
||||
removeMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: name
|
||||
? t("Removed {{name}} from favorites", { name })
|
||||
: t("Removed from favorites"),
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
addMutation.mutate(params);
|
||||
addMutation.mutate(params, {
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: name
|
||||
? t("Added {{name}} to favorites", { name })
|
||||
: t("Added to favorites"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const label = isFavorited
|
||||
// Tooltip label stays short. Accessible name expands to include the item
|
||||
// so screen reader users can distinguish stars on different rows.
|
||||
const tooltipLabel = isFavorited
|
||||
? t("Remove from favorites")
|
||||
: t("Add to favorites");
|
||||
|
||||
const ariaLabel = name
|
||||
? isFavorited
|
||||
? t("Remove {{name}} from favorites", { name })
|
||||
: t("Add {{name}} to favorites", { name })
|
||||
: tooltipLabel;
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={250} withArrow>
|
||||
<Tooltip label={tooltipLabel} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isFavorited ? "yellow" : "gray"}
|
||||
aria-label={label}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={isFavorited}
|
||||
onClick={handleToggle}
|
||||
loading={isPending}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type IFavorite = {
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
isBase: boolean;
|
||||
spaceId: string;
|
||||
};
|
||||
space?: {
|
||||
|
||||
@@ -31,7 +31,12 @@ export default function AddGroupMemberModal() {
|
||||
<>
|
||||
<Button onClick={open}>{t("Add group members")}</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title={t("Add group members")}>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Add group members")}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
|
||||
<MultiUserSelect
|
||||
|
||||
@@ -58,6 +58,7 @@ export function CreateGroupForm() {
|
||||
label={t("Group name")}
|
||||
placeholder={t("e.g Developers")}
|
||||
variant="filled"
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ export default function CreateGroupModal() {
|
||||
<>
|
||||
<Button onClick={open}>{t("Create group")}</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title={t("Create group")}>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Create group")}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<CreateGroupForm />
|
||||
</Modal>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
@@ -18,13 +19,16 @@ const formSchema = z.object({
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
interface EditGroupFormProps {
|
||||
onClose?: () => void;
|
||||
group?: IGroup;
|
||||
}
|
||||
export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||
export function EditGroupForm({ onClose, group: groupProp }: EditGroupFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateGroupMutation = useUpdateGroupMutation();
|
||||
const { isSuccess } = updateGroupMutation;
|
||||
const { groupId } = useParams();
|
||||
const { data: group } = useGroupQuery(groupId);
|
||||
const { groupId: routeGroupId } = useParams();
|
||||
const groupId = groupProp?.id ?? routeGroupId;
|
||||
const { data: queriedGroup } = useGroupQuery(groupProp ? undefined : groupId);
|
||||
const group = groupProp ?? queriedGroup;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
@@ -66,6 +70,7 @@ export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||
label={t("Group name")}
|
||||
placeholder={t("e.g Developers")}
|
||||
variant="filled"
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { Divider, Modal } from "@mantine/core";
|
||||
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
|
||||
interface EditGroupModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
group?: IGroup;
|
||||
}
|
||||
|
||||
export default function EditGroupModal({
|
||||
opened,
|
||||
onClose,
|
||||
group,
|
||||
}: EditGroupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={opened} onClose={onClose} title={t("Edit group")}>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Edit group")}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
<EditGroupForm onClose={onClose} />
|
||||
<EditGroupForm onClose={onClose} group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,18 +10,28 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||
|
||||
export default function GroupActionMenu() {
|
||||
interface GroupActionMenuProps {
|
||||
group?: IGroup;
|
||||
}
|
||||
|
||||
export default function GroupActionMenu(props: GroupActionMenuProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { groupId } = useParams();
|
||||
const { data: group, isLoading } = useGroupQuery(groupId);
|
||||
const { groupId: routeGroupId } = useParams();
|
||||
const groupId = props.group?.id ?? routeGroupId;
|
||||
const { data: queriedGroup } = useGroupQuery(props.group ? undefined : groupId);
|
||||
const group = props.group ?? queriedGroup;
|
||||
const deleteGroupMutation = useDeleteGroupMutation();
|
||||
const navigate = useNavigate();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const onDelete = async () => {
|
||||
await deleteGroupMutation.mutateAsync(groupId);
|
||||
navigate("/settings/groups");
|
||||
// Only navigate away if we're currently viewing this group's detail page.
|
||||
if (routeGroupId === groupId) {
|
||||
navigate("/settings/groups");
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = () =>
|
||||
@@ -53,7 +63,11 @@ export default function GroupActionMenu() {
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="light" aria-label={t("Group menu")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Group actions for {{name}}", { name: group.name })}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
@@ -76,7 +90,7 @@ export default function GroupActionMenu() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditGroupModal opened={opened} onClose={close} />
|
||||
<EditGroupModal opened={opened} onClose={close} group={group} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||
import { Table, Group, Text, Anchor, VisuallyHidden } from "@mantine/core";
|
||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||
@@ -12,6 +12,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
||||
|
||||
export default function GroupList() {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,13 +36,16 @@ export default function GroupList() {
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Group")}</Table.Th>
|
||||
<Table.Th>{t("Members")}</Table.Th>
|
||||
<Table.Th w={60}>
|
||||
<VisuallyHidden>{t("Actions")}</VisuallyHidden>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((group: IGroup, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Tr key={index} className={rowClasses.row}>
|
||||
<Table.Td onMouseEnter={() => prefetchGroupMembers(group.id)}>
|
||||
<Anchor
|
||||
size="sm"
|
||||
@@ -49,6 +54,7 @@ export default function GroupList() {
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
className={rowClasses.link}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
@@ -80,10 +86,13 @@ export default function GroupList() {
|
||||
{formatMemberCount(group.memberCount, t)}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GroupActionMenu group={group} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={2} />
|
||||
<NoTableResults colSpan={3} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
@@ -88,7 +88,13 @@ export default function GroupMembersList() {
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -4,19 +4,20 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useCreatedByQuery } from "@/features/page/queries/page-query";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||
|
||||
type Props = {
|
||||
spaceId?: string;
|
||||
@@ -49,9 +50,10 @@ export default function CreatedByMe({ spaceId }: Props) {
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Tbody>
|
||||
{pages.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Tr key={page.id} className={rowClasses.row}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
className={rowClasses.link}
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
page?.space.slug,
|
||||
@@ -60,17 +62,9 @@ export default function CreatedByMe({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{page.title || t("Untitled")}
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -4,19 +4,20 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query";
|
||||
import { IconFileDescription, IconStar } from "@tabler/icons-react";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconStar } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { getSpaceUrl } from "@/lib/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||
import rowClasses from "@/components/ui/clickable-table-row.module.css";
|
||||
|
||||
interface Props {
|
||||
spaceId?: string;
|
||||
@@ -50,9 +51,10 @@ export default function FavoritesPages({ spaceId }: Props) {
|
||||
<Table.Tbody>
|
||||
{favorites.map((fav) =>
|
||||
fav.page ? (
|
||||
<Table.Tr key={fav.id}>
|
||||
<Table.Tr key={fav.id} className={rowClasses.row}>
|
||||
<Table.Td>
|
||||
<UnstyledButton
|
||||
className={rowClasses.link}
|
||||
component={Link}
|
||||
to={buildPageUrl(
|
||||
fav.space?.slug,
|
||||
@@ -61,17 +63,12 @@ export default function FavoritesPages({ spaceId }: Props) {
|
||||
)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
{fav.page.icon || (
|
||||
<ThemeIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<PageListIcon
|
||||
icon={fav.page.icon}
|
||||
isBase={fav.page.isBase}
|
||||
/>
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{fav.page.title || t("Untitled")}
|
||||
{getPageTitle(fav.page.title, fav.page.isBase, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { format, isThisYear, isToday, isYesterday } from "date-fns";
|
||||
import { isThisYear, isToday, isYesterday } from "date-fns";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
|
||||
export function formatLabelListDate(date: Date): string {
|
||||
const locale = getDateFnsLocale();
|
||||
if (isToday(date)) {
|
||||
return i18n.t("Today, {{time}}", { time: format(date, "h:mma") });
|
||||
return i18n.t("Today, {{time}}", {
|
||||
time: formatLocalized(date, "h:mma", "p", locale),
|
||||
});
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") });
|
||||
return i18n.t("Yesterday, {{time}}", {
|
||||
time: formatLocalized(date, "h:mma", "p", locale),
|
||||
});
|
||||
}
|
||||
if (isThisYear(date)) {
|
||||
return format(date, "MMM dd");
|
||||
if (locale.code?.startsWith("en")) {
|
||||
return formatLocalized(date, "MMM dd", "MMM dd", locale);
|
||||
}
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
return format(date, "MMM dd, yyyy");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { formatRelativeTime } from "../notification.utils";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
@@ -143,7 +143,7 @@ export function NotificationItem({
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{notification.page.title || t("Untitled")}
|
||||
{getPageTitle(notification.page.title, undefined, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -31,14 +31,18 @@ import classes from "../notification.module.css";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [tab, setTab] = useState<NotificationTab>("direct");
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
const [filterMenuOpened, setFilterMenuOpened] = useState(false);
|
||||
const [moreMenuOpened, setMoreMenuOpened] = useState(false);
|
||||
|
||||
const { data: unreadData } = useUnreadCountQuery();
|
||||
const markAllRead = useMarkAllReadMutation();
|
||||
|
||||
const unreadCount = unreadData?.count ?? 0;
|
||||
const isSubMenuOpen = filterMenuOpened || moreMenuOpened;
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
markAllRead.mutate();
|
||||
@@ -51,6 +55,9 @@ export function NotificationPopover() {
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
trapFocus
|
||||
returnFocus
|
||||
closeOnEscape={!isSubMenuOpen}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Notifications")} withArrow>
|
||||
@@ -77,17 +84,29 @@ export function NotificationPopover() {
|
||||
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
aria-labelledby={titleId}
|
||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||
>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Text fw={600} size="sm">
|
||||
<Title id={titleId} order={2} fz="sm" fw={600}>
|
||||
{t("Notifications")}
|
||||
</Text>
|
||||
</Title>
|
||||
<Group gap={4}>
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
opened={filterMenuOpened}
|
||||
onChange={setFilterMenuOpened}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Filter")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("Filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -113,10 +132,21 @@ export function NotificationPopover() {
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
opened={moreMenuOpened}
|
||||
onChange={setMoreMenuOpened}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("More options")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
aria-label={t("More options")}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from "@/i18n.ts";
|
||||
import { INotification } from "./types/notification.types";
|
||||
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
@@ -8,15 +9,15 @@ export function formatRelativeTime(dateStr: string): string {
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMin < 1) return "now";
|
||||
if (diffMin < 1) return i18n.t("now");
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BacklinkDirection,
|
||||
IBacklinkPageItem,
|
||||
} from "@/features/page-details/types/backlink.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { getPageIcon } from "@/lib";
|
||||
|
||||
interface BacklinksListProps {
|
||||
@@ -86,7 +86,7 @@ export function BacklinksList({
|
||||
{getPageIcon(item.icon ?? "")}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
{item.title || t("Untitled")}
|
||||
{getPageTitle(item.title, undefined, t)}
|
||||
</Text>
|
||||
{item.space?.name && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function BacklinksModal({
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title fw={500}>{t("Backlinks")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Stack gap="lg">
|
||||
|
||||
@@ -16,7 +16,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||
|
||||
@@ -139,6 +140,7 @@ function StatsSection({
|
||||
updatedAt: Date | string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lastUpdated = useTimeAgo(updatedAt);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500} c="dimmed">
|
||||
@@ -150,10 +152,7 @@ function StatsSection({
|
||||
label={t("Created")}
|
||||
value={formattedDate(new Date(createdAt))}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("Last updated")}
|
||||
value={timeAgo(new Date(updatedAt))}
|
||||
/>
|
||||
<StatRow label={t("Last updated")} value={lastUpdated} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function HistoryEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !content) return;
|
||||
if (!editor || editor.isDestroyed || !content) return;
|
||||
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||
{t("Page history")}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body
|
||||
p={0}
|
||||
@@ -60,7 +60,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||
{t("Page history")}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody pageId={pageId} />
|
||||
|
||||
@@ -42,6 +42,14 @@ export function useHistoryRestore() {
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!activeHistoryData) return;
|
||||
if (
|
||||
!mainEditor ||
|
||||
mainEditor.isDestroyed ||
|
||||
!mainEditorTitle ||
|
||||
mainEditorTitle.isDestroyed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainEditorTitle
|
||||
.chain()
|
||||
|
||||
@@ -15,15 +15,17 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./breadcrumb.module.css";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import type { TFunction } from "i18next";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function getTitle(name: string, icon: string) {
|
||||
if (icon) {
|
||||
return `${icon} ${name}`;
|
||||
function getTitle(node: SpaceTreeNode, t: TFunction) {
|
||||
const name = getPageTitle(node.name, node.isBase, t);
|
||||
if (node.icon) {
|
||||
return `${node.icon} ${name}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -75,7 +77,7 @@ export default function Breadcrumb() {
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<Text fz={"sm"} className={classes.truncatedText}>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Text>
|
||||
</Button>
|
||||
</Button.Group>
|
||||
@@ -83,7 +85,7 @@ export default function Breadcrumb() {
|
||||
|
||||
const renderAnchor = useCallback(
|
||||
(node: SpaceTreeNode, isCurrent = false) => (
|
||||
<Tooltip label={node.name} key={node.id}>
|
||||
<Tooltip label={getPageTitle(node.name, node.isBase, t)} key={node.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
|
||||
@@ -93,11 +95,11 @@ export default function Breadcrumb() {
|
||||
className={classes.truncatedText}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
{getTitle(node, t)}
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
),
|
||||
[spaceSlug],
|
||||
[spaceSlug, t],
|
||||
);
|
||||
|
||||
const getBreadcrumbItems = () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function CopyPageModal({
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{t("Copy page")}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Text mb="xs" c="dimmed" size="sm">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user