mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 13:14:11 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b218d31ed | |||
| a27e683e06 | |||
| 37f349f040 | |||
| 7dcf5006d3 | |||
| 4f20b43222 | |||
| 06c81a4fed | |||
| 5c3942c159 | |||
| e0809e7104 | |||
| da6793ac87 | |||
| 08e94eb3c1 | |||
| 5a14186f1c | |||
| 6a0bb8d4cb | |||
| fba9f4cb2b | |||
| d8f7c4a822 | |||
| 202685b39f | |||
| fc4a428208 | |||
| 5506eb194b |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "Seite",
|
"page": "Seite",
|
||||||
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
"Page deleted successfully": "Seite erfolgreich gelöscht",
|
||||||
"Page history": "Seitengeschichte",
|
"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.",
|
"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",
|
||||||
"pages": "Seiten",
|
"pages": "Seiten",
|
||||||
|
|||||||
@@ -123,10 +123,7 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page deleted successfully",
|
"Page deleted successfully": "Page deleted successfully",
|
||||||
"Page history": "Page history",
|
"Page history": "Page history",
|
||||||
"Version history for": "Version history for",
|
|
||||||
"document": "document",
|
|
||||||
"Select version": "Select version",
|
"Select version": "Select version",
|
||||||
"Close": "Close",
|
|
||||||
"Highlight changes": "Highlight changes",
|
"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.",
|
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||||
"Pages": "Pages",
|
"Pages": "Pages",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "página",
|
"page": "página",
|
||||||
"Page deleted successfully": "Página eliminada con éxito",
|
"Page deleted successfully": "Página eliminada con éxito",
|
||||||
"Page history": "Historial de la página",
|
"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.",
|
"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",
|
||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "page",
|
"page": "page",
|
||||||
"Page deleted successfully": "Page supprimée avec succès",
|
"Page deleted successfully": "Page supprimée avec succès",
|
||||||
"Page history": "Historique de la page",
|
"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.",
|
"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",
|
||||||
"pages": "pages",
|
"pages": "pages",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "pagina",
|
"page": "pagina",
|
||||||
"Page deleted successfully": "Pagina eliminata con successo",
|
"Page deleted successfully": "Pagina eliminata con successo",
|
||||||
"Page history": "Cronologia della pagina",
|
"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.",
|
"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",
|
||||||
"pages": "pagine",
|
"pages": "pagine",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "ページ",
|
"page": "ページ",
|
||||||
"Page deleted successfully": "ページを削除しました",
|
"Page deleted successfully": "ページを削除しました",
|
||||||
"Page history": "ページ履歴",
|
"Page history": "ページ履歴",
|
||||||
|
"Select version": "バージョンを選択",
|
||||||
|
"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": "ページ",
|
||||||
"pages": "ページ",
|
"pages": "ページ",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "페이지",
|
"page": "페이지",
|
||||||
"Page deleted successfully": "페이지 삭제 완료",
|
"Page deleted successfully": "페이지 삭제 완료",
|
||||||
"Page history": "페이지 기록",
|
"Page history": "페이지 기록",
|
||||||
|
"Select version": "버전 선택",
|
||||||
|
"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": "페이지",
|
||||||
"pages": "페이지",
|
"pages": "페이지",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "pagina",
|
"page": "pagina",
|
||||||
"Page deleted successfully": "Pagina succesvol verwijderd",
|
"Page deleted successfully": "Pagina succesvol verwijderd",
|
||||||
"Page history": "Pagina geschiedenis",
|
"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.",
|
"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",
|
||||||
"pages": "pagina's",
|
"pages": "pagina's",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "página",
|
"page": "página",
|
||||||
"Page deleted successfully": "Página excluída com sucesso",
|
"Page deleted successfully": "Página excluída com sucesso",
|
||||||
"Page history": "Histórico da página",
|
"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.",
|
"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",
|
||||||
"pages": "páginas",
|
"pages": "páginas",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "страница",
|
"page": "страница",
|
||||||
"Page deleted successfully": "Страница успешно удалена",
|
"Page deleted successfully": "Страница успешно удалена",
|
||||||
"Page history": "История страницы",
|
"Page history": "История страницы",
|
||||||
|
"Select version": "Выбрать версию",
|
||||||
|
"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": "Страницы",
|
||||||
"pages": "страницы",
|
"pages": "страницы",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "сторінка",
|
"page": "сторінка",
|
||||||
"Page deleted successfully": "Сторінку успішно видалено",
|
"Page deleted successfully": "Сторінку успішно видалено",
|
||||||
"Page history": "Історія сторінки",
|
"Page history": "Історія сторінки",
|
||||||
|
"Select version": "Вибрати версію",
|
||||||
|
"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": "Сторінки",
|
||||||
"pages": "сторінки",
|
"pages": "сторінки",
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
"page": "个页面",
|
"page": "个页面",
|
||||||
"Page deleted successfully": "页面已成功删除",
|
"Page deleted successfully": "页面已成功删除",
|
||||||
"Page history": "页面历史",
|
"Page history": "页面历史",
|
||||||
|
"Select version": "选择版本",
|
||||||
|
"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": "页面",
|
||||||
"pages": "个页面",
|
"pages": "个页面",
|
||||||
|
|||||||
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
|
|||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
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,
|
IconStrikethrough,
|
||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
|
IconSparkles,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
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 { ColorSelector } from "./color-selector";
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { TextAlignmentSelector } from "./text-alignment-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 { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,14 +41,20 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
|
const showAiMenuRef = useRef(showAiMenu);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
}, [showCommentPopup]);
|
}, [showCommentPopup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showAiMenuRef.current = showAiMenu;
|
||||||
|
}, [showAiMenu]);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
selector: (ctx) => {
|
selector: (ctx) => {
|
||||||
@@ -123,6 +131,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
empty ||
|
empty ||
|
||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
|
showAiMenuRef.current ||
|
||||||
showCommentPopupRef?.current
|
showCommentPopupRef?.current
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -146,9 +155,26 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
// Hide the bubble menu immediately when AI menu is shown
|
||||||
|
if (showAiMenu) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
<BubbleMenu
|
||||||
|
{...bubbleMenuProps}
|
||||||
|
style={{ zIndex: 200, position: "relative" }}
|
||||||
|
>
|
||||||
<div className={classes.bubbleMenu}>
|
<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
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
isOpen={isNodeSelectorOpen}
|
isOpen={isNodeSelectorOpen}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|||||||
import { jwtDecode } from "jwt-decode";
|
import { jwtDecode } from "jwt-decode";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
|
import { EditorAiMenu } from "./components/ai-menu/ai-menu";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -405,6 +406,7 @@ export default function PageEditor({
|
|||||||
|
|
||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
|
<EditorAiMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|||||||
import { EditorProvider } from "@tiptap/react";
|
import { EditorProvider } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
import { Document } from "@tiptap/extension-document";
|
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 { Text } from "@tiptap/extension-text";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mantine-AppShell-main {
|
.mantine-AppShell-main {
|
||||||
padding-top: 0 !important;
|
padding: 0 !important;
|
||||||
min-height: auto !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) {
|
:global(.ProseMirror) {
|
||||||
padding: 0 !important;
|
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 {
|
.active {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import "./css/history-diff.module.css";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||||
@@ -44,21 +43,21 @@ export function HistoryEditor({
|
|||||||
if (previousContent) {
|
if (previousContent) {
|
||||||
try {
|
try {
|
||||||
const schema = editor.schema;
|
const schema = editor.schema;
|
||||||
const docOld = Node.fromJSON(schema, previousContent);
|
const oldContent = Node.fromJSON(schema, previousContent);
|
||||||
const docNew = Node.fromJSON(schema, content);
|
const newContent = Node.fromJSON(schema, content);
|
||||||
|
|
||||||
const tr = recreateTransform(docOld, docNew, {
|
const tr = recreateTransform(oldContent, newContent, {
|
||||||
complexSteps: true,
|
complexSteps: false,
|
||||||
wordDiffs: true,
|
wordDiffs: true,
|
||||||
simplifyDiff: true,
|
simplifyDiff: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const changeSet = ChangeSet.create(docOld).addSteps(
|
const changeSet = ChangeSet.create(oldContent).addSteps(
|
||||||
tr.doc,
|
tr.doc,
|
||||||
tr.mapping.maps,
|
tr.mapping.maps,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const changes = simplifyChanges(changeSet.changes, docNew);
|
const changes = simplifyChanges(changeSet.changes, newContent);
|
||||||
|
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content);
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ export function HistoryEditor({
|
|||||||
changeIndex++;
|
changeIndex++;
|
||||||
const currentIndex = changeIndex;
|
const currentIndex = changeIndex;
|
||||||
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||||
docNew.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||||
if (specialNodeTypes.has(node.type.name)) {
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
const nodeEnd = pos + node.nodeSize;
|
const nodeEnd = pos + node.nodeSize;
|
||||||
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||||
@@ -117,7 +116,7 @@ export function HistoryEditor({
|
|||||||
changeIndex++;
|
changeIndex++;
|
||||||
const currentIndex = changeIndex;
|
const currentIndex = changeIndex;
|
||||||
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||||
docOld.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||||
if (specialNodeTypes.has(node.type.name)) {
|
if (specialNodeTypes.has(node.type.name)) {
|
||||||
const nodeEnd = pos + node.nodeSize;
|
const nodeEnd = pos + node.nodeSize;
|
||||||
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||||
@@ -140,7 +139,7 @@ export function HistoryEditor({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const deletedText = docOld.textBetween(
|
const deletedText = oldContent.textBetween(
|
||||||
change.fromA,
|
change.fromA,
|
||||||
change.toA,
|
change.toA,
|
||||||
"",
|
"",
|
||||||
@@ -161,7 +160,7 @@ export function HistoryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decorationSet = DecorationSet.create(docNew, decorations);
|
decorationSet = DecorationSet.create(newContent, decorations);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("History diff failed:", e);
|
console.error("History diff failed:", e);
|
||||||
editor.commands.setContent(content);
|
editor.commands.setContent(content);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
size="compact-md"
|
size="compact-md"
|
||||||
onClick={() => setHistoryModalOpen(false)}
|
onClick={() => setHistoryModalOpen(false)}
|
||||||
>
|
>
|
||||||
{t("Close")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="compact-md" onClick={confirmRestore}>
|
<Button size="compact-md" onClick={confirmRestore}>
|
||||||
{t("Restore")}
|
{t("Restore")}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
{canRestore && (
|
{canRestore && (
|
||||||
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
|
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
|
||||||
{t("Close")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
<Button onClick={confirmRestore}>{t("Restore")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
+11
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -33,28 +33,28 @@
|
|||||||
"@ai-sdk/google": "^3.0.9",
|
"@ai-sdk/google": "^3.0.9",
|
||||||
"@ai-sdk/openai": "^3.0.11",
|
"@ai-sdk/openai": "^3.0.11",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.12",
|
"@ai-sdk/openai-compatible": "^2.0.12",
|
||||||
"@aws-sdk/client-s3": "3.701.0",
|
"@aws-sdk/client-s3": "3.982.0",
|
||||||
"@aws-sdk/lib-storage": "3.701.0",
|
"@aws-sdk/lib-storage": "3.982.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
"@aws-sdk/s3-request-presigner": "3.982.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^8.3.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@langchain/core": "1.1.13",
|
"@langchain/core": "1.1.18",
|
||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.1.11",
|
"@nestjs/common": "^11.1.11",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.11",
|
"@nestjs/core": "^11.1.13",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "11.0.0",
|
"@nestjs/jwt": "11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.11",
|
"@nestjs/platform-fastify": "^11.1.13",
|
||||||
"@nestjs/platform-socket.io": "^11.1.11",
|
"@nestjs/platform-socket.io": "^11.1.13",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.11",
|
"@nestjs/websockets": "^11.1.13",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "0.0.28",
|
"@react-email/components": "0.0.28",
|
||||||
"@react-email/render": "1.0.2",
|
"@react-email/render": "1.0.2",
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export class HistoryListener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: scope to workspaces
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
async getPageHistory(
|
async getPageHistory(
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export class PageHistoryService {
|
|||||||
constructor(private pageHistoryRepo: PageHistoryRepo) {}
|
constructor(private pageHistoryRepo: PageHistoryRepo) {}
|
||||||
|
|
||||||
async findById(historyId: string): Promise<PageHistory> {
|
async findById(historyId: string): Promise<PageHistory> {
|
||||||
return await this.pageHistoryRepo.findById(historyId);
|
return await this.pageHistoryRepo.findById(historyId, {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findHistoryByPageId(
|
async findHistoryByPageId(
|
||||||
|
|||||||
@@ -17,15 +17,32 @@ import { DB } from '@docmost/db/types/db';
|
|||||||
export class PageHistoryRepo {
|
export class PageHistoryRepo {
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
private baseFields: Array<keyof PageHistory> = [
|
||||||
|
'id',
|
||||||
|
'pageId',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'coverPhoto',
|
||||||
|
'lastUpdatedById',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
'createdAt',
|
||||||
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
pageHistoryId: string,
|
pageHistoryId: string,
|
||||||
trx?: KyselyTransaction,
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
): Promise<PageHistory> {
|
): Promise<PageHistory> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('id', '=', pageHistoryId)
|
.where('id', '=', pageHistoryId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -63,7 +80,7 @@ export class PageHistoryRepo {
|
|||||||
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
@@ -76,12 +93,19 @@ export class PageHistoryRepo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
async findPageLastHistory(
|
||||||
const db = dbOrTx(this.db, trx);
|
pageId: string,
|
||||||
|
opts?: {
|
||||||
|
includeContent?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.select(this.baseFields)
|
||||||
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('pageId', '=', pageId)
|
.where('pageId', '=', pageId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.orderBy('createdAt', 'desc')
|
.orderBy('createdAt', 'desc')
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...3a4b47ec30
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
|
|||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { getMimeType } from '../../../common/helpers';
|
import { getMimeType } from '../../../common/helpers';
|
||||||
import { Upload } from '@aws-sdk/lib-storage';
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
export class S3Driver implements StorageDriver {
|
export class S3Driver implements StorageDriver {
|
||||||
private readonly s3Client: S3Client;
|
private readonly s3Client: S3Client;
|
||||||
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
|
|||||||
|
|
||||||
await upload.done();
|
await upload.done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Logger.error(err);
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
|
|||||||
|
|
||||||
await upload.done();
|
await upload.done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Logger.error(err);
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (shouldDestroyClient && clientToUse) {
|
if (shouldDestroyClient && clientToUse) {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
],
|
],
|
||||||
"cache": true
|
"cache": true
|
||||||
},
|
},
|
||||||
|
"start:dev": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"cache": true
|
"cache": true
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "docmost",
|
"name": "docmost",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.25.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nx run-many -t build",
|
"build": "nx run-many -t build",
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
"@casl/ability": "6.8.0",
|
"@casl/ability": "6.8.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.7.3",
|
"@floating-ui/dom": "^1.7.3",
|
||||||
"@hocuspocus/provider": "3.4.3",
|
"@hocuspocus/provider": "3.4.4",
|
||||||
"@hocuspocus/server": "3.4.3",
|
"@hocuspocus/server": "3.4.4",
|
||||||
"@hocuspocus/transformer": "3.4.3",
|
"@hocuspocus/transformer": "3.4.4",
|
||||||
"@joplin/turndown": "^4.0.74",
|
"@joplin/turndown": "^4.0.74",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "1.1.0",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
# prosemirror-changeset
|
|
||||||
|
|
||||||
This is a helper module that can turn a sequence of document changes
|
|
||||||
into a set of insertions and deletions, for example to display them in
|
|
||||||
a change-tracking interface. Such a set can be built up incrementally,
|
|
||||||
in order to do such change tracking in a halfway performant way during
|
|
||||||
live editing.
|
|
||||||
|
|
||||||
This code is licensed under an [MIT
|
|
||||||
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
|
|
||||||
|
|
||||||
## Programming interface
|
|
||||||
|
|
||||||
Insertions and deletions are represented as ‘spans’—ranges in the
|
|
||||||
document. The deleted spans refer to the original document, whereas
|
|
||||||
the inserted ones point into the current document.
|
|
||||||
|
|
||||||
It is possible to associate arbitrary data values with such spans, for
|
|
||||||
example to track the user that made the change, the timestamp at which
|
|
||||||
it was made, or the step data necessary to invert it again.
|
|
||||||
|
|
||||||
### class Change`<Data = any>`
|
|
||||||
|
|
||||||
A replaced range with metadata associated with it.
|
|
||||||
|
|
||||||
* **`fromA`**`: number`\
|
|
||||||
The start of the range deleted/replaced in the old document.
|
|
||||||
|
|
||||||
* **`toA`**`: number`\
|
|
||||||
The end of the range in the old document.
|
|
||||||
|
|
||||||
* **`fromB`**`: number`\
|
|
||||||
The start of the range inserted in the new document.
|
|
||||||
|
|
||||||
* **`toB`**`: number`\
|
|
||||||
The end of the range in the new document.
|
|
||||||
|
|
||||||
* **`deleted`**`: readonly Span[]`\
|
|
||||||
Data associated with the deleted content. The length of these
|
|
||||||
spans adds up to `this.toA - this.fromA`.
|
|
||||||
|
|
||||||
* **`inserted`**`: readonly Span[]`\
|
|
||||||
Data associated with the inserted content. Length adds up to
|
|
||||||
`this.toB - this.fromB`.
|
|
||||||
|
|
||||||
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
|
|
||||||
This merges two changesets (the end document of x should be the
|
|
||||||
start document of y) into a single one spanning the start of x to
|
|
||||||
the end of y.
|
|
||||||
|
|
||||||
|
|
||||||
### class Span`<Data = any>`
|
|
||||||
|
|
||||||
Stores metadata for a part of a change.
|
|
||||||
|
|
||||||
* **`length`**`: number`\
|
|
||||||
The length of this span.
|
|
||||||
|
|
||||||
* **`data`**`: Data`\
|
|
||||||
The data associated with this span.
|
|
||||||
|
|
||||||
|
|
||||||
### class ChangeSet`<Data = any>`
|
|
||||||
|
|
||||||
A change set tracks the changes to a document from a given point
|
|
||||||
in the past. It condenses a number of step maps down to a flat
|
|
||||||
sequence of replacements, and simplifies replacments that
|
|
||||||
partially undo themselves by comparing their content.
|
|
||||||
|
|
||||||
* **`changes`**`: readonly Change[]`\
|
|
||||||
Replaced regions.
|
|
||||||
|
|
||||||
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
|
|
||||||
Computes a new changeset by adding the given step maps and
|
|
||||||
metadata (either as an array, per-map, or as a single value to be
|
|
||||||
associated with all maps) to the current set. Will not mutate the
|
|
||||||
old set.
|
|
||||||
|
|
||||||
Note that due to simplification that happens after each add,
|
|
||||||
incrementally adding steps might create a different final set
|
|
||||||
than adding all those changes at once, since different document
|
|
||||||
tokens might be matched during simplification depending on the
|
|
||||||
boundaries of the current changed ranges.
|
|
||||||
|
|
||||||
* **`startDoc`**`: Node`\
|
|
||||||
The starting document of the change set.
|
|
||||||
|
|
||||||
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
|
|
||||||
Map the span's data values in the given set through a function
|
|
||||||
and construct a new set with the resulting data.
|
|
||||||
|
|
||||||
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
|
|
||||||
Compare two changesets and return the range in which they are
|
|
||||||
changed, if any. If the document changed between the maps, pass
|
|
||||||
the maps for the steps that changed it as second argument, and
|
|
||||||
make sure the method is called on the old set and passed the new
|
|
||||||
set. The returned positions will be in new document coordinates.
|
|
||||||
|
|
||||||
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
|
|
||||||
Create a changeset with the given base object and configuration.
|
|
||||||
|
|
||||||
The `combine` function is used to compare and combine metadata—it
|
|
||||||
should return null when metadata isn't compatible, and a combined
|
|
||||||
version for a merged range when it is.
|
|
||||||
|
|
||||||
When given, a token encoder determines how document tokens are
|
|
||||||
serialized and compared when diffing the content produced by
|
|
||||||
changes. The default is to just compare nodes by name and text
|
|
||||||
by character, ignoring marks and attributes.
|
|
||||||
|
|
||||||
|
|
||||||
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
|
|
||||||
Simplifies a set of changes for presentation. This makes the
|
|
||||||
assumption that having both insertions and deletions within a word
|
|
||||||
is confusing, and, when such changes occur without a word boundary
|
|
||||||
between them, they should be expanded to cover the entire set of
|
|
||||||
words (in the new document) they touch. An exception is made for
|
|
||||||
single-character replacements.
|
|
||||||
|
|
||||||
|
|
||||||
### interface TokenEncoder`<T>`
|
|
||||||
|
|
||||||
A token encoder can be passed when creating a `ChangeSet` in order
|
|
||||||
to influence the way the library runs its diffing algorithm. The
|
|
||||||
encoder determines how document tokens (such as nodes and
|
|
||||||
characters) are encoded and compared.
|
|
||||||
|
|
||||||
Note that both the encoding and the comparison may run a lot, and
|
|
||||||
doing non-trivial work in these functions could impact
|
|
||||||
performance.
|
|
||||||
|
|
||||||
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
|
|
||||||
Encode a given character, with the given marks applied.
|
|
||||||
|
|
||||||
* **`encodeNodeStart`**`(node: Node) → T`\
|
|
||||||
Encode the start of a node or, if this is a leaf node, the
|
|
||||||
entire node.
|
|
||||||
|
|
||||||
* **`encodeNodeEnd`**`(node: Node) → T`\
|
|
||||||
Encode the end token for the given node. It is valid to encode
|
|
||||||
every end token in the same way.
|
|
||||||
|
|
||||||
* **`compareTokens`**`(a: T, b: T) → boolean`\
|
|
||||||
Compare the given tokens. Should return true when they count as
|
|
||||||
equal.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# prosemirror-recreate-transform
|
|
||||||
|
|
||||||
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
|
|
||||||
|
|
||||||
This is a non-core module of [ProseMirror](http://prosemirror.net).
|
|
||||||
ProseMirror is a well-behaved rich semantic content editor based on
|
|
||||||
contentEditable, with support for collaborative editing and custom
|
|
||||||
document schemas.
|
|
||||||
|
|
||||||
Every change to the document is recorded by ProseMirror as a step.
|
|
||||||
This module allows recreating the steps needed to go from document
|
|
||||||
A to B should these not be available otherwise. Recreating steps
|
|
||||||
can be interesting for example in order to show the changes between
|
|
||||||
two document versions without having access to the original steps.
|
|
||||||
|
|
||||||
Recreating a `Transform` works this way:
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
|
|
||||||
|
|
||||||
let tr = recreateTransform(
|
|
||||||
startDoc,
|
|
||||||
endDoc,
|
|
||||||
{
|
|
||||||
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
|
|
||||||
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
|
|
||||||
simplifyDiffs: true // Whether steps should be merged, where possible
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps
|
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
||||||
// https://github.com/sueddeutsche/prosemirror-recreate-transform
|
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
||||||
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
export { recreateTransform, RecreateTransform } from "./recreateTransform";
|
||||||
export type { Options } from "./recreateTransform";
|
export type { Options } from "./recreateTransform";
|
||||||
|
|||||||
Generated
+1094
-1083
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user