Compare commits

...

29 Commits

Author SHA1 Message Date
463480ae67 v0.2.10 (fix) 2024-08-07 23:03:00 +02:00
2449d69fab v2.9.10 2024-08-07 22:22:49 +02:00
e0d74fcb0e Fix: editor formatting (#137)
* reduce space between list items

* reduce spacing

* Make inline code readable in dark mode
* Disable spellcheck in code

* fix numbered list in toggle block
2024-08-04 10:06:22 +02:00
4967849e3a add SMTP_SECURE 2024-08-02 11:19:12 +02:00
0a447e91bb fix markdown import 2024-07-22 18:39:44 +01:00
48e76aa9f4 v0.2.8 2024-07-22 16:36:06 +01:00
2bd6422a35 Show new workspace role on change 2024-07-22 16:35:00 +01:00
407a1aff3b only owner can assign owner role (#108)
* backend fix: b4bc184cb3
2024-07-22 16:18:09 +01:00
b4bc184cb3 prevent admin role from managing owner role (backend) 2024-07-22 16:16:33 +01:00
109dbdbe02 cleanup log 2024-07-22 15:59:43 +01:00
2df7de5828 fix table commands type error 2024-07-22 15:43:43 +01:00
373fc86e47 preserve details tag in markdown export 2024-07-22 14:09:52 +01:00
5052a9ea40 Support math export in Markdown 2024-07-22 13:20:00 +01:00
cd47c79d86 Make math node handling better 2024-07-22 13:05:07 +01:00
78746938b7 fix export format state 2024-07-22 13:02:13 +01:00
4d2936627c fix: generate ydoc state during page import to prevent duplicate nodes on the editor 2024-07-22 11:02:43 +01:00
d2ecd28047 fix: localize attachment type
* fixes #86
2024-07-22 10:58:32 +01:00
bb92ca75e9 use logger 2024-07-21 21:57:31 +01:00
8f3e2ff663 fix editor placeholder bug 2024-07-21 20:50:08 +01:00
89f6311e46 * Make page import handling better 2024-07-21 20:48:33 +01:00
e5a97d2a26 v0.2.7 2024-07-20 18:21:12 +01:00
7f0fd45f3a singular & plural count 2024-07-20 18:20:10 +01:00
078959dfa0 v0.2.6 2024-07-20 18:05:56 +01:00
937a07059a feat: implement Markdown and HTML page imports (#85)
* page import feature
* make file interceptor common

* replace @tiptap/html
* update tiptap version

* reduce table margin

* update tiptap version

* switch to upstream drag handle lib (fixes table dragging)

* WIP

* Page import module and other fixes

* working page imports

* extract page title from h1 heading

* finalize page imports

* cleanup unused imports

* add menu arrow
2024-07-20 17:59:04 +01:00
227ac30d5e add STMP_SECURE and changed auth config (#81)
* add STMP_SECURE and changed auth config
* relocated logic to mail.provider.ts
2024-07-19 12:54:55 +01:00
a2ae341934 v0.2.5 2024-07-12 18:05:59 +01:00
3c70e40d16 add table header by default 2024-07-12 16:53:33 +01:00
14197d7365 add cookie duration 2024-07-12 14:52:09 +01:00
f388540293 feat: Individual page export in Markdown and HTML formats (#80)
* fix maths node

* render default html width

* Add page export module
* with support for html and markdown exports

* Page export UI
* Add PDF print too

* remove unused import
2024-07-12 14:45:09 +01:00
76 changed files with 2400 additions and 1208 deletions

View File

@ -30,6 +30,7 @@ SMTP_HOST=127.0.0.1
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_SECURE=false
# Postmark driver config
POSTMARK_TOKEN=

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.2.4",
"version": "0.2.10",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -26,6 +26,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"jotai": "^2.8.3",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
@ -41,10 +42,12 @@
"react-router-dom": "^6.24.0",
"socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.10",
"zod": "^3.23.8"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.47.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "20.14.9",

View File

@ -23,7 +23,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
),
);
interface SpaceRoleMenuProps {
interface RoleMenuProps {
roles: IRoleData[];
roleName: string;
onChange?: (value: string) => void;
@ -35,7 +35,7 @@ export default function RoleSelectMenu({
roleName,
onChange,
disabled,
}: SpaceRoleMenuProps) {
}: RoleMenuProps) {
return (
<Menu withArrow>
<Menu.Target>

View File

@ -1,14 +1,17 @@
import Cookies from "js-cookie";
import { createJSONStorage, atomWithStorage } from "jotai/utils";
import { ITokens } from '../types/auth.types';
import { ITokens } from "../types/auth.types";
const cookieStorage = createJSONStorage<ITokens>(() => {
return {
getItem: () => Cookies.get("authTokens"),
setItem: (key, value) => Cookies.set(key, value),
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
removeItem: (key) => Cookies.remove(key),
};
});
export const authTokensAtom = atomWithStorage<ITokens | null>("authTokens", null, cookieStorage);
export const authTokensAtom = atomWithStorage<ITokens | null>(
"authTokens",
null,
cookieStorage,
);

View File

@ -1,9 +1,4 @@
import {
Editor,
NodeViewContent,
NodeViewProps,
NodeViewWrapper,
} from "@tiptap/react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,

View File

@ -184,7 +184,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
{

View File

@ -1,371 +0,0 @@
// MIT - source: https://github.com/NiclasDev63/tiptap-extension-global-drag-handle
import { Extension } from "@tiptap/core";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== "none") {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width,
};
}
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
".tableWrapper",
].join(", "),
),
);
}
function nodePosAtDOM(
node: Element,
view: EditorView,
options: GlobalDragHandleOptions,
) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string },
) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
view.state.doc,
nodePos.before(),
);
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos &&
draggedNodePos <= nodeSelection.$to.pos
);
}
if (
!differentNodeSelected &&
diff !== 0 &&
!(view.state.selection instanceof NodeSelection)
) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(
view.state.doc,
draggedNodePos,
endSelection.$to.pos,
);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
const nodeSelection = NodeSelection.create(
view.state.doc,
draggedNodePos,
);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem"
) {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hide");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hide");
}
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
let scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
}
}
dragHandleElement.addEventListener("drag", onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
dragHandleElement?.removeEventListener(
"dragstart",
onDragHandleDragStart,
);
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
const notDragging = node?.closest(".not-draggable");
//const isNodeInTable = node?.closest("td") || node?.closest("th");
if (
!(node instanceof Element) ||
node.matches("ul, ol") ||
notDragging ||
node.matches(".tableWrapper")
) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem";
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill(
{},
view.state.schema.text(text),
);
const listItem = view.state.schema.nodes.listItem?.createAndFill(
{},
paragraph,
);
const newList = view.state.schema.nodes.orderedList?.createAndFill(
null,
listItem,
);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
const GlobalDragHandle = Extension.create({
name: "globalDragHandle",
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: "globalDragHandle",
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
}),
];
},
});
export default GlobalDragHandle;

View File

@ -4,13 +4,14 @@ import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
@ -23,8 +24,6 @@ import {
DetailsSummary,
MathBlock,
MathInline,
Table,
TableHeader,
TableCell,
TableRow,
TrailingNode,
@ -41,7 +40,7 @@ import {
import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
@ -57,6 +56,11 @@ export const mainExtensions = [
color: "#70CFF8",
},
codeBlock: false,
code: {
HTMLAttributes: {
spellcheck: false,
},
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
@ -66,7 +70,9 @@ export const mainExtensions = [
if (node.type.name === "detailsSummary") {
return "Toggle title";
}
return 'Write anything. Enter "/" for commands';
if (node.type.name === "paragraph") {
return 'Write anything. Enter "/" for commands';
}
},
includeChildren: true,
}),
@ -95,10 +101,16 @@ export const mainExtensions = [
class: "comment-mark",
},
}),
Table,
Table.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
}),
TableRow,
TableCell,
TableHeader,
MathInline.configure({
view: MathInlineView,
}),
@ -124,6 +136,9 @@ export const mainExtensions = [
}),
CodeBlockLowlight.configure({
lowlight,
HTMLAttributes: {
spellcheck: false,
},
}),
Selection,
] as any;

View File

@ -3,7 +3,7 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import { Container } from "@mantine/core";
import { useAtom } from "jotai/index";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
const MemoizedTitleEditor = React.memo(TitleEditor);

View File

@ -31,8 +31,6 @@ import TableCellMenu from "@/features/editor/components/table/table-cell-menu.ts
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import {
handleFileDrop,

View File

@ -86,4 +86,22 @@
font-weight: 700;
}
}
:not(pre) > code {
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
line-height: var(--mantine-line-height);
padding: 2px calc(var(--mantine-spacing-xs) / 2);
border-radius: var(--mantine-radius-sm);
margin: 0;
@mixin where-light {
background-color: var(--code-bg, var(--mantine-color-gray-1));
color: var(--mantine-color-black);
}
@mixin where-dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-gray-4);
}
}
}

View File

@ -20,6 +20,11 @@
outline: none;
}
p {
margin-top: 0.65em;
margin-bottom: 0.65em;
}
ul,
ol {
padding: 0 1rem;
@ -27,6 +32,12 @@
margin-bottom: 0.25rem;
}
ul p,
ol p {
margin-top: 0;
margin-bottom: 0;
}
h1,
h2,
h3,
@ -80,15 +91,9 @@
outline: 2px solid #70cff8;
}
.node-mathInline {
.katex-display {
margin: 0;
}
}
& > .react-renderer {
margin-top: var(--mantine-spacing-xl);
margin-bottom: var(--mantine-spacing-xl);
margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm);
&:first-child {
margin-top: 0;
@ -163,3 +168,4 @@
.actionIconGroup {
background: var(--mantine-color-body);
}

View File

@ -50,8 +50,7 @@
[data-type="detailsContainer"] {
flex: 1;
margin-left: 0.2em;
overflow-x: hidden;
padding: 4px;
word-break: break-word;
overflow-wrap: break-word;

View File

@ -2,5 +2,10 @@
height: 100%;
padding: 8px 20px;
margin: 64px auto;
@media print {
padding: 0;
margin: 0;
}
}

View File

@ -8,5 +8,7 @@
@import "./youtube.css";
@import "./media.css";
@import "./code.css";
@import "./print.css";

View File

@ -4,6 +4,10 @@
color: #adb5bd;
pointer-events: none;
height: 0;
@media print {
display: none;
}
}
.ProseMirror .is-empty::before {
@ -12,9 +16,17 @@
color: #adb5bd;
pointer-events: none;
height: 0;
@media print {
display: none;
}
}
.ProseMirror table .is-editor-empty:first-child::before,
.ProseMirror table .is-empty::before {
content: '';
@media print {
display: none;
}
}

View File

@ -0,0 +1,11 @@
@media print {
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside{
display: none !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
}
}

View File

@ -1,6 +1,6 @@
.tableWrapper {
margin-top: 3rem;
margin-bottom: 3rem;
margin-top: 1rem;
margin-bottom: 1rem;
overflow-x: auto;
& table {
overflow-x: hidden;

View File

@ -17,5 +17,9 @@
&.ProseMirror-selectednode {
background-color: transparent;
}
@media print {
display: none;
}
}
}

View File

@ -1,4 +1,4 @@
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
import { Button, Divider, Group, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useState } from "react";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";

View File

@ -2,7 +2,6 @@ import {
usePageHistoryListQuery,
usePageHistoryQuery,
} from "@/features/page-history/queries/page-history-query";
import { useParams } from "react-router-dom";
import HistoryItem from "@/features/page-history/components/history-item";
import {
activeHistoryIdAtom,

View File

@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
import {
IconArrowsHorizontal,
IconDots,
IconDownload,
IconHistory,
IconLink,
IconMessage,
IconPrinter,
IconTrash,
} from "@tabler/icons-react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard } from "@mantine/hooks";
import { useClipboard, useDisclosure } from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
interface PageHeaderMenuProps {
readOnly?: boolean;
@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
});
const { openDeleteModal } = useDeletePageModal();
const [tree] = useAtom(treeApiAtom);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
notifications.show({ message: "Link copied" });
};
const handlePrint = () => {
setTimeout(() => {
window.print();
}, 250);
};
const openHistoryModal = () => {
setHistoryModalOpen(true);
};
@ -75,55 +86,79 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
return (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} stroke={2} />}
onClick={handleCopyLink}
>
Copy link
</Menu.Item>
<Menu.Divider />
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={handleCopyLink}
>
Copy link
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}>
<Group wrap="nowrap">
<PageWidthToggle label="Full width" />
</Group>
</Menu.Item>
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
<Group wrap="nowrap">
<PageWidthToggle label="Full width" />
</Group>
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={16} stroke={2} />}
onClick={openHistoryModal}
>
Page history
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={16} />}
onClick={openHistoryModal}
>
Page history
</Menu.Item>
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} stroke={2} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<Menu.Divider />
<Menu.Item
leftSection={<IconDownload size={16} />}
onClick={openExportModal}
>
Export
</Menu.Item>
<Menu.Item
leftSection={<IconPrinter size={16} />}
onClick={handlePrint}
>
Print PDF
</Menu.Item>
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<PageExportModal
pageId={page.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}

View File

@ -8,4 +8,8 @@
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
@media print {
display: none;
}
}

View File

@ -0,0 +1,94 @@
import { Modal, Button, Group, Text, Select } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import * as React from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
interface PageExportModalProps {
pageId: string;
open: boolean;
onClose: () => void;
}
export default function PageExportModal({
pageId,
open,
onClose,
}: PageExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const handleExport = async () => {
try {
await exportPage({ pageId: pageId, format });
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export page</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
/>
);
}

View File

@ -0,0 +1,150 @@
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
import {
IconCheck,
IconFileCode,
IconMarkdown,
IconX,
} from "@tabler/icons-react";
import { importPage } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { useAtom } from "jotai";
import { buildTree } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts";
import React from "react";
interface PageImportModalProps {
spaceId: string;
open: boolean;
onClose: () => void;
}
export default function PageImportModal({
spaceId,
open,
onClose,
}: PageImportModalProps) {
return (
<>
<Modal.Root
opened={open}
onClose={onClose}
size={600}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Import pages</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<ImportFormatSelection spaceId={spaceId} onClose={onClose} />
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}
interface ImportFormatSelection {
spaceId: string;
onClose: () => void;
}
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const [treeData, setTreeData] = useAtom(treeDataAtom);
const handleFileUpload = async (selectedFiles: File[]) => {
if (!selectedFiles) {
return;
}
onClose();
const alert = notifications.show({
title: "Importing pages",
message: "Page import is in progress. Please do not close this tab.",
loading: true,
autoClose: false,
});
const pages: IPage[] = [];
let pageCount = 0;
for (const file of selectedFiles) {
try {
const page = await importPage(file, spaceId);
pages.push(page);
pageCount += 1;
} catch (err) {
console.log("Failed to import page", err);
}
}
if (pages?.length > 0 && pageCount > 0) {
const newTreeNodes = buildTree(pages);
const fullTree = treeData.concat(newTreeNodes);
if (newTreeNodes?.length && fullTree?.length > 0) {
setTreeData(fullTree);
}
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
notifications.update({
id: alert,
color: "teal",
title: `Successfully imported ${pageCountText}`,
message: "Your import is complete.",
icon: <IconCheck size={18} />,
loading: false,
autoClose: 5000,
});
} else {
notifications.update({
id: alert,
color: "red",
title: `Failed to import pages`,
message: "Unable to import pages. Please try again.",
icon: <IconX size={18} />,
loading: false,
autoClose: 5000,
});
}
};
return (
<>
<SimpleGrid cols={2}>
<FileButton onChange={handleFileUpload} accept="text/markdown" multiple>
{(props) => (
<Button
justify="start"
variant="default"
leftSection={<IconMarkdown size={18} />}
{...props}
>
Markdown
</Button>
)}
</FileButton>
<FileButton onChange={handleFileUpload} accept="text/html" multiple>
{(props) => (
<Button
justify="start"
variant="default"
leftSection={<IconFileCode size={18} />}
{...props}
>
HTML
</Button>
)}
</FileButton>
</SimpleGrid>
</>
);
}

View File

@ -1,11 +1,13 @@
import api from "@/lib/api-client";
import {
IExportPageParams,
IMovePage,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);
@ -53,18 +55,42 @@ export async function getRecentChanges(
return req.data;
}
export async function exportPage(data: IExportPageParams): Promise<void> {
const req = await api.post("/pages/export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, fileName);
}
export async function importPage(file: File, spaceId: string) {
const formData = new FormData();
formData.append("spaceId", spaceId);
formData.append("file", file);
const req = await api.post<IPage>("/pages/import", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return req.data;
}
export async function uploadFile(file: File, pageId: string) {
const formData = new FormData();
formData.append("pageId", pageId);
formData.append("file", file);
// should be file endpoint
const req = await api.post<IAttachment>("/files/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
// console.log("req", req);
return req;
}

View File

@ -447,9 +447,11 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={() =>
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
</Menu.Item>

View File

@ -44,3 +44,13 @@ export interface IPageInput {
coverPhoto: string;
position: string;
}
export interface IExportPageParams {
pageId: string;
format: ExportFormat;
}
export enum ExportFormat {
HTML = "html",
Markdown = "markdown",
}

View File

@ -2,7 +2,7 @@ import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";

View File

@ -1,4 +1,4 @@
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
import { Modal, Tabs, rem, Group, ScrollArea } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React, { useMemo } from "react";

View File

@ -1,4 +1,4 @@
import { UnstyledButton, Group, Avatar, Text, rem } from "@mantine/core";
import { UnstyledButton, Group, Text } from "@mantine/core";
import classes from "./space-name.module.css";
interface SpaceNameProps {

View File

@ -1,13 +1,15 @@
import {
ActionIcon,
Group,
rem,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconArrowDown,
IconDots,
IconHome,
IconPlus,
IconSearch,
@ -32,6 +34,7 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom);
@ -140,18 +143,20 @@ export function SpaceSidebar() {
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label="Create page"
>
<IconPlus />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
@ -177,3 +182,54 @@ export function SpaceSidebar() {
</>
);
}
interface SpaceMenuProps {
spaceId: string;
onSpaceSettings: () => void;
}
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
return (
<>
<Menu width={200} shadow="md" withArrow>
<Menu.Target>
<Tooltip
label="Import pages & space settings"
withArrow
position="top"
>
<ActionIcon variant="default" size={18} aria-label="Space menu">
<IconDots />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={openImportModal}
leftSection={<IconArrowDown size={16} />}
>
Import pages
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={onSpaceSettings}
leftSection={<IconSettings size={16} />}
>
Space settings
</Menu.Item>
</Menu.Dropdown>
</Menu>
<PageImportModal
spaceId={spaceId}
open={importOpened}
onClose={closeImportModal}
/>
</>
);
}

View File

@ -3,6 +3,7 @@ import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib";
export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery();
@ -50,7 +51,7 @@ export default function SpaceList() {
</Group>
</Table.Td>
<Table.Td>{space.memberCount} members</Table.Td>
<Table.Td>{formatMemberCount(space.memberCount)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>

View File

@ -1,5 +1,4 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import { useParams } from "react-router-dom";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";

View File

@ -95,9 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
{...form.getInputProps("newPassword")}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change password
</Button>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={isLoading} loading={isLoading}>
Change password
</Button>
</Group>
</form>
);
}

View File

@ -1,4 +1,4 @@
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";

View File

@ -1,4 +1,4 @@
import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
import { Group, Table, Text, Badge } from "@mantine/core";
import {
useChangeMemberRoleMutation,
useWorkspaceMembersQuery,
@ -11,11 +11,14 @@ import {
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts";
export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const { isAdmin } = useUserRole();
const { isAdmin, isOwner } = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async (
userId: string,
@ -69,7 +72,7 @@ export default function WorkspaceMembersTable() {
<Table.Td>
<RoleSelectMenu
roles={userRoleData}
roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)

View File

@ -53,9 +53,10 @@ export function useChangeMemberRoleMutation() {
return useMutation<any, Error, any>({
mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => {
// TODO: change in cache instead
notifications.show({ message: "Member role updated successfully" });
queryClient.refetchQueries({
queryKey: ["workspaceMembers", variables.spaceId],
queryKey: ["workspaceMembers"],
});
},
onError: (error) => {

View File

@ -26,11 +26,16 @@ api.interceptors.request.use(
},
(error) => {
return Promise.reject(error);
}
},
);
api.interceptors.response.use(
(response) => {
// we need the response headers
if (response.request.responseURL.includes("/api/pages/export")) {
return response;
}
return response.data;
},
(error) => {
@ -67,7 +72,7 @@ api.interceptors.response.use(
}
}
return Promise.reject(error);
}
},
);
function redirectToLogin() {

View File

@ -22,6 +22,10 @@ export interface IRoleData {
description: string;
}
export interface ApiResponse<T> {
data: T;
}
export type IPaginationMeta = {
limit: number;
page: number;

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.2.4",
"version": "0.2.10",
"description": "",
"author": "",
"private": true,
@ -58,8 +58,10 @@
"fastify": "^4.28.0",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0",
"happy-dom": "^14.12.3",
"kysely": "^0.27.3",
"kysely-migration-cli": "^0.4.2",
"marked": "^13.0.2",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nestjs-kysely": "^1.0.0",

View File

@ -12,6 +12,8 @@ import { QueueModule } from './integrations/queue/queue.module';
import { StaticModule } from './integrations/static/static.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module';
@Module({
imports: [
@ -23,6 +25,8 @@ import { HealthModule } from './integrations/health/health.module';
QueueModule,
StaticModule,
HealthModule,
ImportModule,
ExportModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
}),

View File

@ -10,6 +10,8 @@ import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header';
import {
Callout,
Comment,
@ -19,16 +21,18 @@ import {
LinkExtension,
MathBlock,
MathInline,
Table,
TableCell,
TableHeader,
TableRow,
TiptapImage,
TiptapVideo,
TrailingNode,
} from '@docmost/editor-ext';
import { generateHTML, generateJSON } from '@tiptap/html';
import { generateText, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
export const tiptapExtensions = [
StarterKit,
@ -60,7 +64,7 @@ export const tiptapExtensions = [
Callout,
] as any;
export function jsonToHtml(tiptapJson: JSONContent) {
export function jsonToHtml(tiptapJson: any) {
return generateHTML(tiptapJson, tiptapExtensions);
}

View File

@ -0,0 +1,21 @@
import { Extensions, getSchema, JSONContent } from '@tiptap/core';
import { DOMSerializer, Node } from '@tiptap/pm/model';
import { Window } from 'happy-dom';
export function generateHTML(doc: JSONContent, extensions: Extensions): string {
const schema = getSchema(extensions);
const contentNode = Node.fromJSON(schema, doc);
const window = new Window();
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
contentNode.content,
{
document: window.document as unknown as Document,
},
);
const serializer = new window.XMLSerializer();
// @ts-ignore
return serializer.serializeToString(fragment as unknown as Node);
}

View File

@ -0,0 +1,20 @@
import { Extensions, getSchema } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import { Window, DOMParser as HappyDomParser } from 'happy-dom';
export function generateJSON(
html: string,
extensions: Extensions,
options?: ParseOptions,
): Record<string, any> {
const schema = getSchema(extensions);
const window = new Window();
const dom = new HappyDomParser(window).parseFromString(
html,
'text/html',
).body;
// @ts-ignore
return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
}

View File

@ -0,0 +1,2 @@
export * from './generateHTML.js';
export * from './generateJSON.js';

View File

@ -9,7 +9,7 @@ import { Observable } from 'rxjs';
import { FastifyRequest } from 'fastify';
@Injectable()
export class AttachmentInterceptor implements NestInterceptor {
export class FileInterceptor implements NestInterceptor {
public intercept(
context: ExecutionContext,
next: CallHandler,

View File

@ -16,7 +16,7 @@ import {
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { AttachmentInterceptor } from './interceptors/attachment.interceptor';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@ -63,7 +63,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(AttachmentInterceptor)
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@ -176,7 +176,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(AttachmentInterceptor)
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,

View File

@ -59,7 +59,7 @@ export class AttachmentService {
});
} catch (err) {
// delete uploaded file on error
console.error(err);
this.logger.error(err);
}
return attachment;

View File

@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@ -217,11 +218,21 @@ export class WorkspaceService {
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase();
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (user.role === userRoleDto.role) {
// prevent ADMIN from managing OWNER role
if (
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return user;
}
@ -238,7 +249,7 @@ export class WorkspaceService {
await this.userRepo.updateUser(
{
role: userRoleDto.role,
role: newRole,
},
user.id,
workspaceId,

View File

@ -87,6 +87,13 @@ export class EnvironmentService {
return parseInt(this.configService.get<string>('SMTP_PORT'));
}
getSmtpSecure(): boolean {
const secure = this.configService
.get<string>('SMTP_SECURE', 'false')
.toLowerCase();
return secure === 'true';
}
getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME');
}

View File

@ -0,0 +1,26 @@
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
export enum ExportFormat {
HTML = 'html',
Markdown = 'markdown',
}
export class ExportPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsString()
@IsIn(['html', 'markdown'])
format: ExportFormat;
@IsOptional()
@IsBoolean()
includeFiles?: boolean;
}

View File

@ -0,0 +1,69 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ExportService } from './export.service';
import { ExportPageDto } from './dto/export-dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
import { getMimeType } from '../../common/helpers';
@Controller()
export class ImportController {
constructor(
private readonly exportService: ExportService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/export')
async exportPage(
@Body() dto: ExportPageDto,
@AuthUser() user: User,
@Res() res: FastifyReply,
) {
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
});
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const rawContent = await this.exportService.exportPage(dto.format, page);
const fileExt = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'Untitled') + fileExt;
res.headers({
'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'attachment; filename="' + fileName + '"',
});
res.send(rawContent);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExportService } from './export.service';
import { ImportController } from './export.controller';
@Module({
providers: [ExportService],
controllers: [ImportController],
})
export class ExportModule {}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { jsonToHtml } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class ExportService {
async exportPage(format: string, page: Page) {
const titleNode = {
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: page.title }],
};
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
if (page.title) {
prosemirrorJson.content.unshift(titleNode);
}
const pageHtml = jsonToHtml(prosemirrorJson);
if (format === ExportFormat.HTML) {
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
}
if (format === ExportFormat.Markdown) {
return turndown(pageHtml);
}
return;
}
}

View File

@ -0,0 +1,122 @@
import * as TurndownService from '@joplin/turndown';
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
export function turndown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
});
const tables = TurndownPluginGfm.tables;
const strikethrough = TurndownPluginGfm.strikethrough;
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
turndownService.use([
tables,
strikethrough,
highlightedCodeBlock,
taskList,
callout,
preserveDetail,
listParagraph,
mathInline,
mathBlock,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
}
function listParagraph(turndownService: TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
replacement: (content: any, node: HTMLInputElement) => {
if (node.parentElement?.nodeName === 'LI') {
return content;
}
return `\n\n${content}\n\n`;
},
});
}
function callout(turndownService: TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const calloutType = node.getAttribute('data-callout-type');
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
},
});
}
function taskList(turndownService: TurndownService) {
turndownService.addRule('taskListItem', {
filter: function (node: HTMLInputElement) {
return (
node.getAttribute('data-type') === 'taskItem' &&
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: any, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
},
});
}
function preserveDetail(turndownService: TurndownService) {
turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'DETAILS';
},
replacement: function (content: any, node: HTMLInputElement) {
// TODO: preserve summary of nested details
const summary = node.querySelector(':scope > summary');
let detailSummary = '';
if (summary) {
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
summary.remove();
}
const detailsContent = turndownService.turndown(node.innerHTML);
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
},
});
}
function mathInline(turndownService: TurndownService) {
turndownService.addRule('mathInline', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'SPAN' &&
node.getAttribute('data-type') === 'mathInline'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return `$${content}$`;
},
});
}
function mathBlock(turndownService: TurndownService) {
turndownService.addRule('mathBlock', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' &&
node.getAttribute('data-type') === 'mathBlock'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return `\n$$${content}$$\n`;
},
});
}

View File

@ -0,0 +1,12 @@
import { ExportFormat } from './dto/export-dto';
export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) {
return '.html';
}
if (format === ExportFormat.Markdown) {
return '.md';
}
return;
}

View File

@ -0,0 +1,4 @@
export enum ImportFormat {
HTML = 'html',
Markdown = 'markdown',
}

View File

@ -0,0 +1,87 @@
import {
BadRequestException,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Logger,
Post,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import * as path from 'path';
import { MAX_FILE_SIZE } from '../../core/attachment/attachment.constants';
import { ImportService } from './import.service';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@Controller()
export class ImportController {
private readonly logger = new Logger(ImportController.name);
constructor(
private readonly importService: ImportService,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@UseInterceptors(FileInterceptor)
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/import')
async importPage(
@Req() req: any,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const validFileExtensions = ['.md', '.html'];
const maxFileSize = bytes(MAX_FILE_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
if (
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
) {
throw new BadRequestException('Invalid import file type.');
}
const spaceId = file.fields?.spaceId?.value;
if (!spaceId) {
throw new BadRequestException('spaceId or format not found');
}
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.importService.importPage(file, user.id, spaceId, workspace.id);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ImportService } from './import.service';
import { ImportController } from './import.controller';
@Module({
providers: [ImportService],
controllers: [ImportController]
})
export class ImportModule {}

View File

@ -0,0 +1,161 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { MultipartFile } from '@fastify/multipart';
import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path';
import {
htmlToJson,
tiptapExtensions,
} from '../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId } from '../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { markdownToHtml } from './utils/marked.utils';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
@Injectable()
export class ImportService {
private readonly logger = new Logger(ImportService.name);
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async importPage(
filePromise: Promise<MultipartFile>,
userId: string,
spaceId: string,
workspaceId: string,
): Promise<void> {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileName = sanitize(file.filename).slice(0, 255).split('.')[0];
const fileExtension = path.extname(file.filename).toLowerCase();
const fileContent = fileBuffer.toString();
let prosemirrorState = null;
let createdPage = null;
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
}
} catch (err) {
const message = 'Error processing file content';
this.logger.error(message, err);
throw new BadRequestException(message);
}
if (!prosemirrorState) {
const message = 'Failed to create ProseMirror state';
this.logger.error(message);
throw new BadRequestException(message);
}
const { title, prosemirrorJson } =
this.extractTitleAndRemoveHeading(prosemirrorState);
const pageTitle = title || fileName;
if (prosemirrorJson) {
try {
const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
ydoc: await this.createYdoc(prosemirrorJson),
position: pagePosition,
spaceId: spaceId,
creatorId: userId,
workspaceId: workspaceId,
lastUpdatedById: userId,
});
this.logger.debug(
`Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`,
);
} catch (err) {
const message = 'Failed to create imported page';
this.logger.error(message, err);
throw new BadRequestException(message);
}
}
return createdPage;
}
async processMarkdown(markdownInput: string): Promise<any> {
try {
const html = await markdownToHtml(markdownInput);
return this.processHTML(html);
} catch (err) {
throw err;
}
}
async processHTML(htmlInput: string): Promise<any> {
try {
return htmlToJson(htmlInput);
} catch (err) {
throw err;
}
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
this.logger.debug(`Converting prosemirror json state to ydoc`);
const ydoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
}
return null;
}
extractTitleAndRemoveHeading(prosemirrorState: any) {
let title = null;
if (
prosemirrorState?.content?.length > 0 &&
prosemirrorState.content[0].type === 'heading' &&
prosemirrorState.content[0].attrs?.level === 1
) {
title = prosemirrorState.content[0].content[0].text;
// remove h1 header node from state
prosemirrorState.content.shift();
}
return { title, prosemirrorJson: prosemirrorState };
}
async getNewPagePosition(spaceId: string): Promise<string> {
const lastPage = await this.db
.selectFrom('pages')
.select(['id', 'position'])
.where('spaceId', '=', spaceId)
.orderBy('position', 'desc')
.limit(1)
.where('parentPageId', 'is', null)
.executeTakeFirst();
if (lastPage) {
return generateJitteredKeyBetween(lastPage.position, null);
} else {
return generateJitteredKeyBetween(null, null);
}
}
}

View File

@ -0,0 +1,36 @@
import { marked } from 'marked';
marked.use({
renderer: {
// @ts-ignore
list(body: string, isOrdered: boolean, start: number) {
if (isOrdered) {
const startAttr = start !== 1 ? ` start="${start}"` : '';
return `<ol ${startAttr}>\n${body}</ol>\n`;
}
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : '';
return `<ul${dataType}>\n${body}</ul>\n`;
},
// @ts-ignore
listitem({ text, raw, task: isTask, checked: isChecked }): string {
if (!isTask) {
return `<li>${text}</li>\n`;
}
const checkedAttr = isChecked
? 'data-checked="true"'
: 'data-checked="false"';
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
},
},
});
export async function markdownToHtml(markdownInput: string): Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
const markdown = markdownInput
.replace(YAML_FONT_MATTER_REGEX, '')
.trimStart();
return marked.parse(markdown);
}

View File

@ -26,16 +26,21 @@ export const mailDriverConfigProvider = {
switch (driver) {
case MailOption.SMTP:
let auth = undefined;
if (environmentService.getSmtpUsername() && environmentService.getSmtpPassword()) {
auth = {
user: environmentService.getSmtpUsername(),
pass: environmentService.getSmtpPassword(),
};
}
return {
driver,
config: {
host: environmentService.getSmtpHost(),
port: environmentService.getSmtpPort(),
connectionTimeout: 30 * 1000, // 30 seconds
auth: {
user: environmentService.getSmtpUsername(),
pass: environmentService.getSmtpPassword(),
},
auth,
secure: environmentService.getSmtpSecure(),
} as SMTPTransport.Options,
};

View File

@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.2.4",
"version": "0.2.10",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@ -21,41 +21,43 @@
"@hocuspocus/provider": "^2.13.5",
"@hocuspocus/server": "^2.13.5",
"@hocuspocus/transformer": "^2.13.5",
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "^2.2.1",
"@tiptap/core": "^2.4.0",
"@tiptap/extension-code-block": "^2.4.0",
"@tiptap/extension-collaboration": "^2.4.0",
"@tiptap/extension-collaboration-cursor": "^2.4.0",
"@tiptap/extension-color": "^2.4.0",
"@tiptap/extension-document": "^2.4.0",
"@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-highlight": "^2.4.0",
"@tiptap/extension-history": "^2.4.0",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-list-item": "^2.4.0",
"@tiptap/extension-list-keymap": "^2.4.0",
"@tiptap/extension-mention": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-subscript": "^2.4.0",
"@tiptap/extension-superscript": "^2.4.0",
"@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-task-item": "^2.4.0",
"@tiptap/extension-task-list": "^2.4.0",
"@tiptap/extension-text": "^2.4.0",
"@tiptap/extension-text-align": "^2.4.0",
"@tiptap/extension-text-style": "^2.4.0",
"@tiptap/extension-typography": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/extension-youtube": "^2.4.0",
"@tiptap/html": "^2.4.0",
"@tiptap/pm": "^2.4.0",
"@tiptap/react": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/suggestion": "^2.4.0",
"@tiptap/core": "^2.5.4",
"@tiptap/extension-code-block": "^2.5.4",
"@tiptap/extension-collaboration": "^2.5.4",
"@tiptap/extension-collaboration-cursor": "^2.5.4",
"@tiptap/extension-color": "^2.5.4",
"@tiptap/extension-document": "^2.5.4",
"@tiptap/extension-heading": "^2.5.4",
"@tiptap/extension-highlight": "^2.5.4",
"@tiptap/extension-history": "^2.5.4",
"@tiptap/extension-image": "^2.5.4",
"@tiptap/extension-link": "^2.5.4",
"@tiptap/extension-list-item": "^2.5.4",
"@tiptap/extension-list-keymap": "^2.5.4",
"@tiptap/extension-mention": "^2.5.4",
"@tiptap/extension-placeholder": "^2.5.4",
"@tiptap/extension-subscript": "^2.5.4",
"@tiptap/extension-superscript": "^2.5.4",
"@tiptap/extension-table": "^2.5.4",
"@tiptap/extension-table-cell": "^2.5.4",
"@tiptap/extension-table-header": "^2.5.4",
"@tiptap/extension-table-row": "^2.5.4",
"@tiptap/extension-task-item": "^2.5.4",
"@tiptap/extension-task-list": "^2.5.4",
"@tiptap/extension-text": "^2.5.4",
"@tiptap/extension-text-align": "^2.5.4",
"@tiptap/extension-text-style": "^2.5.4",
"@tiptap/extension-typography": "^2.5.4",
"@tiptap/extension-underline": "^2.5.4",
"@tiptap/extension-youtube": "^2.5.4",
"@tiptap/html": "^2.5.4",
"@tiptap/pm": "^2.5.4",
"@tiptap/react": "^2.5.4",
"@tiptap/starter-kit": "^2.5.4",
"@tiptap/suggestion": "^2.5.4",
"cross-env": "^7.0.3",
"fractional-indexing-jittered": "^0.9.1",
"ioredis": "^5.4.1",
@ -66,8 +68,8 @@
"devDependencies": {
"@nx/js": "19.3.2",
"@types/uuid": "^10.0.0",
"nx": "19.3.2",
"concurrently": "^8.2.2",
"nx": "19.5.6",
"tsx": "^4.15.7"
},
"workspaces": {

View File

@ -1,7 +1,7 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { IAttachment } from "client/src/lib/types";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
const uploadKey = new PluginKey("image-upload");

View File

@ -57,9 +57,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
parseHTML: (element) => element.getAttribute("width"),
renderHTML: (attributes: ImageAttributes) => ({
"data-width": attributes.width,
width: attributes.width,
}),
},
align: {

View File

@ -36,7 +36,9 @@ export const MathBlock = Node.create({
return {
text: {
default: "",
parseHTML: (element) => element.innerHTML.split("$")[1],
parseHTML: (element) => {
return element.innerHTML;
},
},
};
},
@ -44,7 +46,7 @@ export const MathBlock = Node.create({
parseHTML() {
return [
{
tag: "div",
tag: `div[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false;
},
@ -55,8 +57,8 @@ export const MathBlock = Node.create({
renderHTML({ HTMLAttributes }) {
return [
"div",
{},
["div", { "data-katex": true }, `$${HTMLAttributes.text}$`],
{ "data-type": this.name, "data-katex": true },
`${HTMLAttributes.text}`,
];
},
@ -64,10 +66,6 @@ export const MathBlock = Node.create({
return ReactNodeViewRenderer(this.options.view);
},
renderText({ node }) {
return node.attrs.text;
},
addCommands() {
return {
setMathBlock:

View File

@ -37,7 +37,9 @@ export const MathInline = Node.create<MathInlineOption>({
return {
text: {
default: "",
parseHTML: (element) => element.innerHTML.split("$")[1],
parseHTML: (element) => {
return element.innerHTML;
},
},
};
},
@ -45,7 +47,7 @@ export const MathInline = Node.create<MathInlineOption>({
parseHTML() {
return [
{
tag: "span",
tag: `span[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false;
},
@ -55,16 +57,12 @@ export const MathInline = Node.create<MathInlineOption>({
renderHTML({ HTMLAttributes }) {
return [
"div",
{},
["span", { "data-katex": true }, `$${HTMLAttributes.text}$`],
"span",
{ "data-type": this.name, "data-katex": true },
`${HTMLAttributes.text}`,
];
},
renderText({ node }) {
return node.attrs.text;
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},

View File

@ -1,3 +0,0 @@
import TiptapTableHeader from "@tiptap/extension-table-header";
export const TableHeader = TiptapTableHeader.configure();

View File

@ -1,4 +1,2 @@
export * from "./table-extension";
export * from "./header";
export * from "./row";
export * from "./cell";

View File

@ -1,7 +0,0 @@
import TiptapTable from "@tiptap/extension-table";
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
});

View File

@ -0,0 +1,17 @@
// repetition for now
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

View File

@ -3,7 +3,7 @@ import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
import { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model";
import { Table } from "./table/table-extension";
import Table from "@tiptap/extension-table";
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));

View File

@ -1,7 +1,7 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { IAttachment } from "client/src/lib/types";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
const uploadKey = new PluginKey("video-upload");

View File

@ -62,9 +62,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
parseHTML: (element) => element.getAttribute("width"),
renderHTML: (attributes: VideoAttributes) => ({
"data-width": attributes.width,
width: attributes.width,
}),
},
size: {

1716
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff