merge main

This commit is contained in:
Philipinho
2026-05-22 13:56:51 +01:00
811 changed files with 68477 additions and 12951 deletions
@@ -1,10 +1,27 @@
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import {
getAttachmentInfo,
uploadFile,
} from "@/features/page/services/page-service.ts";
const ATTACHMENT_NODE_TYPES = [
"image",
"video",
"audio",
"pdf",
"attachment",
"excalidraw",
"drawio",
];
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
export const handlePaste = (
editor: Editor,
@@ -35,7 +52,6 @@ export const handlePaste = (
const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
// pasted link must be from the same workspace/domain and must not be on a selection
if (!empty || match[2] !== window.location.host) {
@@ -43,12 +59,6 @@ export const handlePaste = (
return false;
}
// for now, we only support internal links from the same space
// compare space name
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
return false;
}
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
@@ -63,19 +73,165 @@ export const handlePaste = (
return true;
}
if (event.clipboardData?.files.length) {
const htmlData = event.clipboardData?.getData("text/html");
const hasHtmlTable = htmlData && /<table[\s>]/i.test(htmlData);
if (event.clipboardData?.files.length && !hasHtmlTable) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadPdfAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
}
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
const pasteFrom = editor.state.selection.from;
setTimeout(() => {
reuploadPastedAttachments(editor, pageId, pasteFrom);
}, 0);
}
return false;
};
async function reuploadPastedAttachments(
editor: Editor,
pageId: string,
pasteFrom: number,
) {
const pasteEnd = editor.state.selection.from;
if (pasteEnd <= pasteFrom) return;
type PastedNode = {
pos: number;
attachmentId: string;
nodeTypeName: string;
src?: string;
url?: string;
fileName?: string;
};
const pastedNodes: PastedNode[] = [];
const seenAttachmentIds = new Set<string>();
editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => {
if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
if (!attachmentId) return;
const src = node.attrs.src || node.attrs.url || "";
const match = ATTACHMENT_URL_RE.exec(src);
if (!match) return;
const fileName =
node.attrs.name || src.split("/").pop() || "file";
pastedNodes.push({
pos,
attachmentId,
nodeTypeName: node.type.name,
src: node.attrs.src,
url: node.attrs.url,
fileName,
});
seenAttachmentIds.add(attachmentId);
});
if (pastedNodes.length === 0) return;
const attachmentPageMap = new Map<string, string | null>();
await Promise.all(
[...seenAttachmentIds].map(async (id) => {
try {
const info = await getAttachmentInfo(id);
attachmentPageMap.set(id, info.pageId);
} catch {
attachmentPageMap.set(id, null);
}
}),
);
const nodesToReupload = pastedNodes.filter((n) => {
const ownerPageId = attachmentPageMap.get(n.attachmentId);
return ownerPageId !== null && ownerPageId !== pageId;
});
if (nodesToReupload.length === 0) return;
const uniqueNodes = new Map<string, (typeof nodesToReupload)[0]>();
for (const node of nodesToReupload) {
if (!uniqueNodes.has(node.attachmentId)) {
uniqueNodes.set(node.attachmentId, node);
}
}
const reuploadResults = new Map<
string,
{ id: string; fileName: string; fileSize: number; mimeType: string }
>();
await Promise.all(
[...uniqueNodes.values()].map(async (node) => {
const fileUrl = node.src || node.url;
if (!fileUrl) return;
try {
const response = await fetch(fileUrl, { credentials: "include" });
if (!response.ok) return;
const blob = await response.blob();
const file = new File([blob], node.fileName, { type: blob.type });
const newAttachment = await uploadFile(file, pageId);
reuploadResults.set(node.attachmentId, {
id: newAttachment.id,
fileName: newAttachment.fileName,
fileSize: newAttachment.fileSize,
mimeType: newAttachment.mimeType,
});
} catch {
// keep original reference on failure
}
}),
);
if (reuploadResults.size === 0) return;
editor.chain().command(({ tr }) => {
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
for (const pastedNode of sorted) {
const result = reuploadResults.get(pastedNode.attachmentId);
if (!result) continue;
const node = tr.doc.nodeAt(pastedNode.pos);
if (!node || node.attrs.attachmentId !== pastedNode.attachmentId)
continue;
const newAttrs = { ...node.attrs };
newAttrs.attachmentId = result.id;
if (newAttrs.src) {
newAttrs.src = `/api/files/${result.id}/${result.fileName}`;
}
if (newAttrs.url) {
newAttrs.url = `/api/files/${result.id}/${result.fileName}`;
}
if (pastedNode.nodeTypeName === "attachment") {
newAttrs.name = result.fileName;
newAttrs.mime = result.mimeType;
newAttrs.size = result.fileSize;
}
tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs);
}
return true;
}).run();
}
export const handleFileDrop = (
editor: Editor,
event: DragEvent,
@@ -93,6 +249,7 @@ export const handleFileDrop = (
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
@@ -0,0 +1,35 @@
import classes from "./node-resize.module.css";
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
): HTMLElement {
const handle = document.createElement("div");
handle.dataset.resizeHandle = direction;
handle.style.position = "absolute";
handle.className = classes.handle;
if (direction === "left") {
handle.style.left = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
} else if (direction === "right") {
handle.style.right = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
}
const bar = document.createElement("div");
bar.className = classes.handleBar;
handle.appendChild(bar);
return handle;
}
export function buildResizeClasses(nodeClass: string) {
return {
container: `${classes.container} ${nodeClass}`,
wrapper: classes.wrapper,
resizing: classes.resizing,
};
}
@@ -0,0 +1,75 @@
.container {
display: flex;
}
.wrapper {
position: relative;
border-radius: 8px;
overflow: visible;
max-width: 100%;
}
.wrapper img,
.wrapper video {
height: auto !important;
}
.resizing {
user-select: none;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.handle[data-resize-handle="left"] {
left: -8px;
}
.handle[data-resize-handle="right"] {
right: -8px;
}
.wrapper:hover .handle {
opacity: 1;
}
.container:global(.ProseMirror-selectednode) .handle {
opacity: 1;
}
.resizing .handle {
opacity: 1;
}
.handleBar {
width: 4px;
height: 48px;
border-radius: 4px;
transition: background-color 0.15s ease;
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
}
.handle:hover .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
.resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
@media print {
.handle {
display: none !important;
}
}
@@ -1,13 +1,11 @@
.wrapper {
position: relative;
width: 100%;
overflow: hidden;
overflow: visible;
border-radius: 8px;
}
.resizing {
user-select: none;
cursor: ns-resize;
}
.overlay {
@@ -20,12 +18,118 @@
background: transparent;
}
.cornerHandle {
position: absolute;
width: 24px;
height: 24px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
touch-action: none;
-webkit-user-select: none;
user-select: none;
&::before,
&::after {
content: "";
position: absolute;
border-radius: 1px;
background-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-5)
);
transition: background-color 0.15s ease;
}
&::before {
width: 20px;
height: 3px;
}
&::after {
width: 3px;
height: 20px;
}
&:hover::before,
&:hover::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
}
.cornerHandleTL {
top: -2px;
left: -2px;
cursor: nwse-resize;
&::before {
top: 0;
left: 0;
}
&::after {
top: 0;
left: 0;
}
}
.cornerHandleTR {
top: -2px;
right: -2px;
cursor: nesw-resize;
&::before {
top: 0;
right: 0;
}
&::after {
top: 0;
right: 0;
}
}
.cornerHandleBL {
bottom: -2px;
left: -2px;
cursor: nesw-resize;
&::before {
bottom: 0;
left: 0;
}
&::after {
bottom: 0;
left: 0;
}
}
.cornerHandleBR {
bottom: -2px;
right: -2px;
cursor: nwse-resize;
&::before {
bottom: 0;
right: 0;
}
&::after {
bottom: 0;
right: 0;
}
}
.resizeHandleBottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
bottom: -4px;
left: 20px;
right: 20px;
height: 12px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.2s ease;
@@ -36,61 +140,53 @@
touch-action: none;
-webkit-user-select: none;
user-select: none;
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.05)
);
}
&:hover {
@mixin light {
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
}
@mixin dark {
background: linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.1)
);
}
}
}
.wrapper:hover .resizeHandleBottom,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizeBar {
width: 50px;
height: 4px;
height: 3px;
border-radius: 2px;
transition: background-color 0.2s ease;
transition: background-color 0.15s ease;
background-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-5)
);
}
@mixin light {
background-color: var(--mantine-color-gray-5);
}
.resizeHandleBottom:hover .resizeBar {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
@mixin dark {
background-color: var(--mantine-color-gray-6);
}
.wrapper:hover .cornerHandle,
.wrapper:hover .resizeHandleBottom,
.wrapper:global(.ProseMirror-selectednode) .cornerHandle,
.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom,
.resizing .cornerHandle,
.resizing .resizeHandleBottom {
opacity: 1;
}
.resizing .cornerHandle::before,
.resizing .cornerHandle::after {
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
.resizeHandleBottom:hover .resizeBar,
.resizing .resizeBar {
@mixin light {
background-color: var(--mantine-color-gray-7);
}
background-color: light-dark(
var(--mantine-color-blue-6),
var(--mantine-color-blue-4)
);
}
@mixin dark {
background-color: var(--mantine-color-gray-4);
@media print {
.cornerHandle,
.resizeHandleBottom {
display: none !important;
}
}
@@ -2,111 +2,172 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
import clsx from "clsx";
import classes from "./resizable-wrapper.module.css";
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
br: { x: 1, y: 1 },
bl: { x: -1, y: 1 },
tr: { x: 1, y: -1 },
tl: { x: -1, y: -1 },
bottom: { x: 0, y: 1 },
};
const HANDLE_CURSOR: Record<Handle, string> = {
br: "nwse-resize",
tl: "nwse-resize",
bl: "nesw-resize",
tr: "nesw-resize",
bottom: "ns-resize",
};
const CORNER_CLASSES: Record<string, string> = {
tl: classes.cornerHandleTL,
tr: classes.cornerHandleTR,
bl: classes.cornerHandleBL,
br: classes.cornerHandleBR,
};
interface ResizableWrapperProps {
children: ReactNode;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
onResize?: (height: number) => void;
onResize?: (width: number, height: number) => void;
isEditable?: boolean;
className?: string;
showHandles?: "always" | "hover";
direction?: "vertical" | "horizontal" | "both";
selected?: boolean;
}
type DragState = {
handle: Handle;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
};
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
children,
initialWidth = 640,
initialHeight = 480,
minWidth = 200,
maxWidth = 1200,
minHeight = 200,
maxHeight = 1200,
onResize,
isEditable = true,
className,
showHandles = "hover",
direction = "vertical",
selected = false,
}) => {
const [resizeParams, setResizeParams] = useState<{
initialSize: number;
initialClientY: number;
initialClientX: number;
} | null>(null);
const [currentHeight, setCurrentHeight] = useState(initialHeight);
const [isResizing, setIsResizing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const widthRef = useRef(initialWidth);
const heightRef = useRef(initialHeight);
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
useEffect(() => {
if (!resizeParams) return;
if (!dragRef.current && wrapperRef.current) {
widthRef.current = initialWidth;
heightRef.current = initialHeight;
wrapperRef.current.style.width = `${initialWidth}px`;
wrapperRef.current.style.height = `${initialHeight}px`;
}
}, [initialWidth, initialHeight]);
const handleMouseMove = (e: MouseEvent) => {
if (!wrapperRef.current) return;
const handleMouseMove = useRef((e: MouseEvent) => {
const drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
if (direction === "vertical" || direction === "both") {
const deltaY = e.clientY - resizeParams.initialClientY;
const newHeight = Math.min(
Math.max(resizeParams.initialSize + deltaY, minHeight),
maxHeight
);
setCurrentHeight(newHeight);
wrapperRef.current.style.height = `${newHeight}px`;
}
const sign = HANDLE_SIGN[drag.handle];
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
const deltaY = e.clientY - drag.startY;
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
heightRef.current = newHeight;
wrapperRef.current.style.height = `${newHeight}px`;
if (sign.x !== 0) {
const deltaX = e.clientX - drag.startX;
const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
widthRef.current = newWidth;
wrapperRef.current.style.width = `${newWidth}px`;
}
}).current;
const handleMouseUp = useRef(() => {
dragRef.current = null;
setIsResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
onResizeRef.current?.(widthRef.current, heightRef.current);
}).current;
const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => {
e.preventDefault();
e.stopPropagation();
dragRef.current = {
handle,
startX: e.clientX,
startY: e.clientY,
startWidth: widthRef.current,
startHeight: heightRef.current,
};
const handleMouseUp = () => {
setResizeParams(null);
if (onResize && currentHeight !== initialHeight) {
onResize(currentHeight);
}
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
setIsResizing(true);
document.body.style.cursor = HANDLE_CURSOR[handle];
document.body.style.userSelect = "none";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [handleMouseMove, handleMouseUp]);
useEffect(() => {
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
}, [handleMouseMove, handleMouseUp]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizeParams({
initialSize: currentHeight,
initialClientY: e.clientY,
initialClientX: e.clientX,
});
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
}, [currentHeight]);
const shouldShowHandles =
isEditable &&
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
return (
<div
ref={wrapperRef}
className={clsx(classes.wrapper, className, {
[classes.resizing]: !!resizeParams,
[classes.resizing]: isResizing,
})}
style={{ height: currentHeight }}
style={{ width: widthRef.current, height: heightRef.current }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{!!resizeParams && <div className={classes.overlay} />}
{shouldShowHandles && direction === "vertical" && (
<div
className={classes.resizeHandleBottom}
onMouseDown={handleResizeStart}
>
<div className={classes.resizeBar} />
</div>
{isResizing && <div className={classes.overlay} />}
{shouldShowHandles && (
<>
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
<div
key={corner}
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
onMouseDown={(e) => handleResizeStart(e, corner)}
/>
))}
<div
className={classes.resizeHandleBottom}
onMouseDown={(e) => handleResizeStart(e, "bottom")}
>
<div className={classes.resizeBar} />
</div>
</>
)}
</div>
);
};
};
@@ -0,0 +1,29 @@
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 8px;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
}
.toolbar :global(.mantine-ActionIcon-root) {
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
}
.toolbar .active {
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 2px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
}
@@ -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 };
}