Compare commits

..

16 Commits

Author SHA1 Message Date
Philipinho 3b218d31ed switch to menu component 2026-02-06 21:32:42 -08:00
Philipinho a27e683e06 clear existing output 2026-02-06 18:06:11 -08:00
Philipinho 37f349f040 sanitize output 2026-02-06 17:56:28 -08:00
Philipinho 7dcf5006d3 prebuild @editor-ext 2026-02-06 17:55:01 -08:00
Philipinho 4f20b43222 - Add insert below and copy option 2026-02-06 12:14:33 -08:00
Arek Nawo 06c81a4fed feat(ee): AI menu 2026-02-06 15:16:37 +01:00
Philip Okugbe 5c3942c159 fix safari print (#1907) 2026-02-04 08:26:03 -08:00
Philipinho e0809e7104 v0.25.1 2026-02-04 07:10:13 -08:00
Philipinho da6793ac87 downgrade tiptap version (fix menu) 2026-02-04 07:09:48 -08:00
Philip Okugbe 08e94eb3c1 update dependencies (#1902) 2026-02-03 15:15:23 -08:00
Philipinho 5a14186f1c fix global diff css 2026-02-03 13:47:56 -08:00
Philipinho 6a0bb8d4cb v0.25.0 2026-02-03 13:18:03 -08:00
Philip Okugbe fba9f4cb2b New Crowdin updates (#1896)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-02-03 13:16:27 -08:00
Philipinho d8f7c4a822 cleanup 2026-02-03 13:12:39 -08:00
Philipinho 202685b39f fix translation 2026-02-03 13:09:56 -08:00
Philip Okugbe fc4a428208 fix(deps): update dependencies (#1898) 2026-02-03 13:04:00 -08:00
34 changed files with 1872 additions and 1267 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.25.0-beta.1",
"version": "0.25.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -123,6 +123,8 @@
"page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten",
"pages": "Seiten",
@@ -123,10 +123,7 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Version history for": "Version history for",
"document": "document",
"Select version": "Select version",
"Close": "Close",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages",
@@ -123,6 +123,8 @@
"page": "página",
"Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
"Pages": "Páginas",
"pages": "páginas",
@@ -123,6 +123,8 @@
"page": "page",
"Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
"Pages": "Pages",
"pages": "pages",
@@ -123,6 +123,8 @@
"page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine",
"pages": "pagine",
@@ -123,6 +123,8 @@
"page": "ページ",
"Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴",
"Select version": "バージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ",
"pages": "ページ",
@@ -123,6 +123,8 @@
"page": "페이지",
"Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지",
"pages": "페이지",
@@ -123,6 +123,8 @@
"page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's",
"pages": "pagina's",
@@ -123,6 +123,8 @@
"page": "página",
"Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
"Pages": "Páginas",
"pages": "páginas",
@@ -123,6 +123,8 @@
"page": "страница",
"Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы",
"pages": "страницы",
@@ -123,6 +123,8 @@
"page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки",
"pages": "сторінки",
@@ -123,6 +123,8 @@
"page": "个页面",
"Page deleted successfully": "页面已成功删除",
"Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面",
"pages": "个页面",
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
@@ -0,0 +1,22 @@
.aiMenu {
display: flex;
flex-direction: column;
width: 100%;
min-height: 2.25rem;
}
.menuItemSelected {
background-color: var(--mantine-color-gray-1);
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.resultPreviewWrapper {
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
@@ -0,0 +1,303 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconSend } from "@tabler/icons-react";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query";
import { AiAction } from "@/ee/ai/types/ai.types";
import { CommandItem, commandItems, CommandSet } from "./command-items";
import { CommandSelector } from "./command-selector";
import { ResultPreview } from "./result-preview";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
interface EditorAiMenuProps {
editor: Editor | null;
}
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [prompt, setPrompt] = useState("");
const [output, setOutput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
const [menuPlacement, setMenuPlacement] = useState<{
top: number;
left: number;
width: number;
}>({
top: 0,
left: 0,
width: 0,
});
const currentItems = useMemo(() => {
return commandItems[activeCommandSet].filter((item) => {
return item.name.toLowerCase().includes(prompt.toLowerCase());
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
const { view } = editor;
const { to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const cursorCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
setMenuPlacement({
top: cursorCoords.bottom + topOffset,
left: editorRect.left + editorPadding,
width: editorRect.width - editorPadding * 2,
});
}, [editor, showAiMenu, isSmBreakpoint]);
const resetMenu = useCallback(() => {
setPrompt("");
setOutput("");
setActiveCommandSet("main");
setLastAction(null);
aiGenerateStreamMutation.reset();
}, [aiGenerateStreamMutation.reset]);
const debouncedUpdateMenuPlacement = useDebouncedCallback(
updateMenuPlacement,
60,
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
let command: CommandItem | null = item || null;
if (!command) {
if (!prompt) return;
command = {
id: "custom",
name: "Custom",
action: AiAction.CUSTOM,
prompt,
};
}
const { from, to } = editor.state.selection;
const content = editor.state.doc.textBetween(from, to);
setOutput("");
setIsLoading(true);
aiGenerateStreamMutation.mutate({
action: command.action,
prompt: command.prompt,
content,
onChunk: (chunk) => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setIsLoading(false);
setActiveCommandSet("result");
},
onError: () => {
setIsLoading(false);
resetMenu();
},
});
setLastAction(command);
},
[
editor,
prompt,
isLoading,
aiGenerateStreamMutation.mutateAsync,
resetMenu,
],
);
const handleCommand = useCallback(
(item?: CommandItem) => {
setPrompt("");
if (!item) {
return handleGenerate();
}
if (item.id === "back") {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
chain.setTextSelection(editor.state.selection.to);
}
chain.insertContent(marked.parse(output)).run();
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
editor
.chain()
.focus()
.setTextSelection(editor.state.selection.to)
.insertContent(marked.parse(output))
.run();
return setShowAiMenu(false);
}
if (item.id === "result-copy") {
navigator.clipboard.writeText(output);
return setShowAiMenu(false);
}
if (item.id === "result-discard") {
setOutput("");
return resetMenu();
}
if (item.id === "result-try-again" && lastAction) {
return handleGenerate(lastAction);
}
if (item.subCommandSet) {
return setActiveCommandSet(item.subCommandSet);
}
return handleGenerate(item);
},
[editor, output, lastAction, handleGenerate, resetMenu],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
const totalItems = currentItems.length;
const cycleSize = totalItems + 1;
if (event.key === "Escape") {
return setShowAiMenu(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
return setSelectedIndex((selectedIndex) => {
const direction = event.key === "ArrowDown" ? 1 : -1;
const newIndex = selectedIndex + direction;
if (newIndex < -1) return cycleSize - 1;
if (newIndex >= cycleSize) return 0;
return newIndex;
});
}
if (event.key === "Enter") {
event.preventDefault();
return handleCommand(currentItems[selectedIndex]);
}
},
[currentItems, selectedIndex],
);
useEffect(() => {
if (!editor) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
debouncedUpdateMenuPlacement();
});
updateMenuPlacement();
editor.on("focus", handleClose);
editor.on("blur", handleClose);
window.addEventListener("resize", debouncedUpdateMenuPlacement);
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.observe(editor.view.dom);
return () => {
editor.off("focus", handleClose);
editor.off("blur", handleClose);
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.disconnect();
};
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
useEffect(() => {
if (showAiMenu) {
resetMenu();
}
}, [showAiMenu, resetMenu]);
useEffect(() => {
// Focus input when menu opens or command set changes
requestAnimationFrame(() => {
inputRef.current?.focus();
});
}, [showAiMenu, isLoading, currentItems]);
useEffect(() => {
if (!currentItems.length) {
setSelectedIndex(-1);
}
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
}, [prompt, activeCommandSet, currentItems]);
if (!showAiMenu) return null;
return createPortal(
<div
style={{
zIndex: 200,
position: "fixed",
top: menuPlacement.top,
left: menuPlacement.left,
width: menuPlacement.width,
pointerEvents: "none",
}}
>
<div
className={classes.aiMenu}
style={{ pointerEvents: "auto" }}
tabIndex={0}
ref={containerRef}
>
<ResultPreview output={output} isLoading={isLoading} />
<CommandSelector
selectedIndex={selectedIndex}
isLoading={isLoading}
output={output}
currentItems={currentItems}
handleCommand={handleCommand}
>
<TextInput
ref={inputRef}
style={{ width: "100%" }}
placeholder="Ask AI..."
data-autofocus
value={prompt}
disabled={isLoading}
onChange={(e) => setPrompt(e.currentTarget.value)}
rightSection={
<Tooltip label="Ask AI">
<ActionIcon
disabled={!prompt || isLoading}
variant="transparent"
onClick={() => handleGenerate()}
>
<IconSend size={16} />
</ActionIcon>
</Tooltip>
}
onKeyDown={handleKeyDown}
/>
</CommandSelector>
</div>
</div>,
document.body,
);
};
export { EditorAiMenu };
@@ -0,0 +1,164 @@
import { AiAction } from "@/ee/ai/types/ai.types";
import {
IconSparkles,
IconArrowsMaximize,
IconArrowsMinimize,
IconWriting,
IconHelp,
IconList,
IconMoodSmile,
IconLanguage,
IconTrash,
IconRefresh,
IconChevronLeft,
IconCheck,
IconArrowDownLeft,
IconCopy,
IconTextPlus,
IconAlignJustified,
} from "@tabler/icons-react";
interface CommandItem {
name: string;
id: string;
icon?: typeof IconSparkles;
action?: AiAction;
prompt?: string;
subCommandSet?: CommandSet;
}
type CommandSet = "main" | "tone" | "translate" | "result";
const mainItems: CommandItem[] = [
{
id: "improve-writing",
name: "Improve writing",
icon: IconSparkles,
action: AiAction.IMPROVE_WRITING,
},
{
id: "fix-spelling-grammar",
name: "Fix spelling & grammar",
icon: IconCheck,
action: AiAction.FIX_SPELLING_GRAMMAR,
},
{
id: "make-longer",
name: "Make longer",
icon: IconTextPlus,
action: AiAction.MAKE_LONGER,
},
{
id: "make-shorter",
name: "Make shorter",
icon: IconAlignJustified,
action: AiAction.MAKE_SHORTER,
},
{
id: "continue-writing",
name: "Continue writing",
icon: IconWriting,
action: AiAction.CONTINUE_WRITING,
},
{
id: "explain",
name: "Explain",
icon: IconHelp,
action: AiAction.CUSTOM,
prompt: "Explain this text",
},
{
id: "summarize",
name: "Summarize",
icon: IconList,
action: AiAction.SUMMARIZE,
},
{
id: "change-tone",
name: "Change tone",
icon: IconMoodSmile,
subCommandSet: "tone",
},
{
id: "translate",
name: "Translate",
icon: IconLanguage,
subCommandSet: "translate",
},
];
const toneItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "tone-professional",
name: "Professional",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Professional",
},
{
id: "tone-casual",
name: "Casual",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Casual",
},
{
id: "tone-friendly",
name: "Friendly",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Friendly",
},
];
const translateItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "translate-english",
name: "English",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "English",
},
{
id: "translate-french",
name: "French",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "French",
},
{
id: "translate-german",
name: "German",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "German",
},
];
const resultItems: CommandItem[] = [
{ id: "result-replace", name: "Replace", icon: IconCheck },
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
{ id: "result-copy", name: "Copy", icon: IconCopy },
{ id: "result-discard", name: "Discard", icon: IconTrash },
{
id: "result-try-again",
name: "Try again",
icon: IconRefresh,
},
];
const commandItems: Record<CommandSet, CommandItem[]> = {
main: mainItems,
tone: toneItems,
translate: translateItems,
result: resultItems,
};
export type { CommandItem, CommandSet };
export { commandItems };
@@ -0,0 +1,71 @@
import { Loader, Menu, ScrollArea } from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { ReactNode } from "react";
import { CommandItem } from "./command-items";
import classes from "./ai-menu.module.css";
interface CommandSelectorProps {
selectedIndex: number;
isLoading: boolean;
output: string;
currentItems: CommandItem[];
children: ReactNode;
handleCommand(item: CommandItem): void;
}
const CommandSelector = ({
selectedIndex,
children,
isLoading,
output,
currentItems,
handleCommand,
}: CommandSelectorProps) => {
return (
<Menu
opened={!isLoading && currentItems.length > 0}
position="bottom-start"
offset={4}
width={250}
trapFocus={false}
shadow="lg"
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
{currentItems.map((item, index) => {
const isSelected = selectedIndex === index;
const showLoader =
isLoading && output === "" && !item.subCommandSet;
return (
<Menu.Item
key={item.id}
className={isSelected ? classes.menuItemSelected : undefined}
leftSection={
showLoader ? (
<Loader size={14} />
) : item.icon ? (
<item.icon size={16} />
) : undefined
}
rightSection={
item.subCommandSet ? (
<IconChevronRight size={14} />
) : undefined
}
onClick={() => handleCommand(item)}
disabled={isLoading}
>
{item.name}
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
};
export { CommandSelector };
@@ -0,0 +1,31 @@
import { Loader, Paper, Text } from "@mantine/core";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { memo } from "react";
import classes from "./ai-menu.module.css";
interface ResultPreviewProps {
output: string;
isLoading: boolean;
}
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
if (!output && !isLoading) return;
const parsedOutput = `${marked.parse(output)}`;
return (
<Paper p="sm" mb={4} shadow="lg" withBorder>
<Text size="sm" component="div">
{parsedOutput && (
<div
className={classes.resultPreviewWrapper}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
/>
)}
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
</Text>
</Paper>
);
});
export { ResultPreview };
@@ -9,10 +9,11 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -25,6 +26,7 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export interface BubbleMenuItem {
name: string;
@@ -39,14 +41,20 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
@@ -123,6 +131,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -146,9 +155,26 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<BubbleMenu
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
>
<div className={classes.bubbleMenu}>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
Ask AI
</Button>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "./components/ai-menu/ai-menu";
interface PageEditorProps {
pageId: string;
@@ -405,6 +406,7 @@ export default function PageEditor({
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai";
@@ -8,7 +8,7 @@
}
.mantine-AppShell-main {
padding-top: 0 !important;
padding: 0 !important;
min-height: auto !important;
}
@@ -1,38 +0,0 @@
.diffSummary {
border: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: rem(10px);
padding: rem(12px);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
:global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
:global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
:global(.history-diff-node-added) {
outline: rem(2px) solid light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
:global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
@@ -16,6 +16,36 @@
:global(.ProseMirror) {
padding: 0 !important;
}
& :global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
& :global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
& :global(.history-diff-node-added) {
outline: rem(2px) solid
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
& :global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
}
.active {
@@ -1,5 +1,4 @@
import "@/features/editor/styles/index.css";
import "./css/history-diff.module.css";
import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
@@ -157,7 +157,7 @@ function HistoryList({ pageId }: Props) {
size="compact-md"
onClick={() => setHistoryModalOpen(false)}
>
{t("Close")}
{t("Cancel")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
@@ -157,7 +157,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
{canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Close")}
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.0-beta.1",
"version": "0.25.1",
"description": "",
"author": "",
"private": true,
@@ -33,13 +33,13 @@
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@langchain/core": "1.1.1",
"@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client;
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally {
if (shouldDestroyClient && clientToUse) {
+5
View File
@@ -6,6 +6,11 @@
],
"cache": true
},
"start:dev": {
"dependsOn": [
"^build"
]
},
"lint": {
"cache": true
}
+31 -31
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.25.0-beta.1",
"version": "0.25.1",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -23,39 +23,39 @@
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@hocuspocus/provider": "3.4.3",
"@hocuspocus/server": "3.4.3",
"@hocuspocus/transformer": "3.4.3",
"@hocuspocus/provider": "3.4.4",
"@hocuspocus/server": "3.4.4",
"@hocuspocus/transformer": "3.4.4",
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "3.19.0",
"@tiptap/extension-code-block": "3.19.0",
"@tiptap/extension-collaboration": "3.19.0",
"@tiptap/extension-collaboration-caret": "3.19.0",
"@tiptap/extension-color": "3.19.0",
"@tiptap/extension-document": "3.19.0",
"@tiptap/extension-heading": "3.19.0",
"@tiptap/extension-highlight": "3.19.0",
"@tiptap/extension-history": "3.19.0",
"@tiptap/extension-image": "3.19.0",
"@tiptap/extension-link": "3.19.0",
"@tiptap/extension-list": "3.19.0",
"@tiptap/extension-placeholder": "3.19.0",
"@tiptap/extension-subscript": "3.19.0",
"@tiptap/extension-superscript": "3.19.0",
"@tiptap/extension-table": "3.19.0",
"@tiptap/extension-text": "3.19.0",
"@tiptap/extension-text-align": "3.19.0",
"@tiptap/extension-text-style": "3.19.0",
"@tiptap/extension-typography": "3.19.0",
"@tiptap/extension-unique-id": "^3.19.0",
"@tiptap/extension-youtube": "3.19.0",
"@tiptap/html": "3.19.0",
"@tiptap/pm": "3.19.0",
"@tiptap/react": "3.19.0",
"@tiptap/starter-kit": "3.19.0",
"@tiptap/suggestion": "3.19.0",
"@tiptap/core": "3.17.1",
"@tiptap/extension-code-block": "3.17.1",
"@tiptap/extension-collaboration": "3.17.1",
"@tiptap/extension-collaboration-caret": "3.17.1",
"@tiptap/extension-color": "3.17.1",
"@tiptap/extension-document": "3.17.1",
"@tiptap/extension-heading": "3.17.1",
"@tiptap/extension-highlight": "3.17.1",
"@tiptap/extension-history": "3.17.1",
"@tiptap/extension-image": "3.17.1",
"@tiptap/extension-link": "3.17.1",
"@tiptap/extension-list": "3.17.1",
"@tiptap/extension-placeholder": "3.17.1",
"@tiptap/extension-subscript": "3.17.1",
"@tiptap/extension-superscript": "3.17.1",
"@tiptap/extension-table": "3.17.1",
"@tiptap/extension-text": "3.17.1",
"@tiptap/extension-text-align": "3.17.1",
"@tiptap/extension-text-style": "3.17.1",
"@tiptap/extension-typography": "3.17.1",
"@tiptap/extension-unique-id": "^3.17.1",
"@tiptap/extension-youtube": "3.17.1",
"@tiptap/html": "3.17.1",
"@tiptap/pm": "3.17.1",
"@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.17.1",
"@types/qrcode": "^1.5.5",
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
+1147 -1181
View File
File diff suppressed because it is too large Load Diff