diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e057d15e..68bef0f2 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -341,6 +341,7 @@ "Insert horizontal rule divider": "Insert horizontal rule divider", "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", + "Upload any audio from your device.": "Upload any audio from your device.", "Upload any file from your device.": "Upload any file from your device.", "Uploading {{name}}": "Uploading {{name}}", "Uploading file": "Uploading file", @@ -351,6 +352,12 @@ "Divider": "Divider.", "Quote": "Quote.", "Image": "Image.", + "Audio": "Audio.", + "Embed PDF": "Embed PDF", + "Upload and embed a PDF file.": "Upload and embed a PDF file.", + "Embed as PDF": "Embed as PDF", + "Failed to load PDF": "Failed to load PDF", + "Convert to attachment": "Convert to attachment", "File attachment": "File attachment.", "Toggle block": "Toggle block.", "Callout": "Callout.", @@ -723,5 +730,7 @@ "Publish": "Publish.", "Security": "Security.", "Enforce SSO": "Enforce SSO.", - "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password." + "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file" } diff --git a/apps/client/src/ee/page-permission/components/page-share-modal.tsx b/apps/client/src/ee/page-permission/components/page-share-modal.tsx index 01b91a7c..0a19b318 100644 --- a/apps/client/src/ee/page-permission/components/page-share-modal.tsx +++ b/apps/client/src/ee/page-permission/components/page-share-modal.tsx @@ -71,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) { ) : null } variant="default" - onClick={open} + onClick={() => { + setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish"); + open(); + }} > {t("Share")} diff --git a/apps/client/src/features/editor/components/attachment/attachment-view.tsx b/apps/client/src/features/editor/components/attachment/attachment-view.tsx index e3281e64..f6c13c80 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -1,17 +1,43 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; +import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; -import { IconDownload, IconPaperclip } from "@tabler/icons-react"; +import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react"; import { useHover } from "@mantine/hooks"; import { formatBytes } from "@/lib"; import { useTranslation } from "react-i18next"; +import { useCallback } from "react"; export default function AttachmentView(props: NodeViewProps) { const { t } = useTranslation(); - const { node, selected } = props; - const { url, name, size } = node.attrs; + const { editor, node, getPos, selected } = props; + const { url, name, size, mime, attachmentId } = node.attrs; const { hovered, ref } = useHover(); + const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf"); + + const handleEmbedAsPdf = useCallback(() => { + const pos = getPos(); + if (pos === undefined || !url) return; + + const nodeSize = node.nodeSize; + + editor + .chain() + .insertContentAt( + { from: pos, to: pos + nodeSize }, + { + type: "pdf", + attrs: { + src: url, + name, + attachmentId, + size, + }, + }, + ) + .run(); + }, [editor, getPos, node, url, name, attachmentId]); + return ( @@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) { {url && (selected || hovered) && ( - - - - - + + {isPdf && editor.isEditable && ( + + + + + + )} + + + + + + )} diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx new file mode 100644 index 00000000..3ca1950d --- /dev/null +++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx @@ -0,0 +1,123 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; +import { useCallback } from "react"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { + EditorMenuProps, + ShouldShowProps, +} from "@/features/editor/components/table/types/types.ts"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { + IconDownload, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import classes from "../common/toolbar-menu.module.css"; + +export function AudioMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const audioAttrs = ctx.editor.getAttributes("audio"); + + return { + isAudio: ctx.editor.isActive("audio"), + src: audioAttrs?.src || null, + }; + }, + }); + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("audio") && editor.getAttributes("audio").src; + }, + [editor], + ); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "audio"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + return ( + +
+ + + + + + + + + + + +
+
+ ); +} + +export default AudioMenu; diff --git a/apps/client/src/features/editor/components/audio/audio-view.module.css b/apps/client/src/features/editor/components/audio/audio-view.module.css new file mode 100644 index 00000000..5773ea82 --- /dev/null +++ b/apps/client/src/features/editor/components/audio/audio-view.module.css @@ -0,0 +1,37 @@ +.audioWrapper { + display: flex; + justify-content: center; + align-items: center; + max-width: 100%; + border-radius: 8px; + overflow: hidden; +} + +.skeleton { + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 0%; + } + } +} + +.audio { + display: block; + width: 100%; + border-radius: 8px; +} diff --git a/apps/client/src/features/editor/components/audio/audio-view.tsx b/apps/client/src/features/editor/components/audio/audio-view.tsx new file mode 100644 index 00000000..a353ce45 --- /dev/null +++ b/apps/client/src/features/editor/components/audio/audio-view.tsx @@ -0,0 +1,65 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { Group, Loader, Text } from "@mantine/core"; +import { useMemo } from "react"; +import { getFileUrl } from "@/lib/config.ts"; +import { isInternalFileUrl } from "@docmost/editor-ext"; +import classes from "./audio-view.module.css"; +import { useTranslation } from "react-i18next"; + +export default function AudioView(props: NodeViewProps) { + const { t } = useTranslation(); + const { editor, node } = props; + const { src, placeholder } = node.attrs; + + const safeSrc = useMemo(() => { + if (!src || !isInternalFileUrl(src)) return null; + return getFileUrl(src); + }, [src]); + + const previewSrc = useMemo(() => { + editor.storage.shared.audioPreviews = + editor.storage.shared.audioPreviews || {}; + + if (placeholder?.id) { + return editor.storage.shared.audioPreviews[placeholder.id]; + } + + return null; + }, [placeholder, editor]); + + return ( + +
+ {safeSrc && ( +
+
+ ); +} diff --git a/apps/client/src/features/editor/components/audio/upload-audio-action.tsx b/apps/client/src/features/editor/components/audio/upload-audio-action.tsx new file mode 100644 index 00000000..e3df5775 --- /dev/null +++ b/apps/client/src/features/editor/components/audio/upload-audio-action.tsx @@ -0,0 +1,36 @@ +import { handleAudioUpload } from "@docmost/editor-ext"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { notifications } from "@mantine/notifications"; +import { getFileUploadSizeLimit } from "@/lib/config.ts"; +import { formatBytes } from "@/lib"; +import i18n from "@/i18n.ts"; + +export const uploadAudioAction = handleAudioUpload({ + onUpload: async (file: File, pageId: string): Promise => { + try { + return await uploadFile(file, pageId); + } catch (err) { + notifications.show({ + color: "red", + message: err?.response.data.message, + }); + throw err; + } + }, + validateFn: (file) => { + if (!file.type.includes("audio/")) { + return false; + } + + if (file.size > getFileUploadSizeLimit()) { + notifications.show({ + color: "red", + message: i18n.t("File exceeds the {{limit}} attachment limit", { + limit: formatBytes(getFileUploadSizeLimit()), + }), + }); + return false; + } + return true; + }, +}); diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 6407d835..85d49872 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -1,6 +1,7 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; +import { uploadPdfAction } from "../pdf/upload-pdf-action"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { Editor } from "@tiptap/core"; @@ -12,6 +13,8 @@ import { const ATTACHMENT_NODE_TYPES = [ "image", "video", + "audio", + "pdf", "attachment", "excalidraw", "drawio", @@ -63,6 +66,7 @@ export const handlePaste = ( const pos = editor.state.selection.from; uploadImageAction(file, editor, pos, pageId); uploadVideoAction(file, editor, pos, pageId); + uploadPdfAction(file, editor, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId); } return true; @@ -229,6 +233,7 @@ export const handleFileDrop = ( uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); + uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId); } return true; diff --git a/apps/client/src/features/editor/components/common/node-resize-handles.ts b/apps/client/src/features/editor/components/common/node-resize-handles.ts index 0785845d..38e10a9a 100644 --- a/apps/client/src/features/editor/components/common/node-resize-handles.ts +++ b/apps/client/src/features/editor/components/common/node-resize-handles.ts @@ -1,5 +1,5 @@ -import type { ResizableNodeViewDirection } from "@tiptap/core"; import classes from "./node-resize.module.css"; +import { ResizableNodeViewDirection } from "@docmost/editor-ext"; export function createResizeHandle( direction: ResizableNodeViewDirection, diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css index 0d0a7688..edfa8c3b 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -20,8 +20,8 @@ .cornerHandle { position: absolute; - width: 36px; - height: 36px; + width: 24px; + height: 24px; z-index: 2; opacity: 0; transition: opacity 0.2s ease; @@ -42,13 +42,13 @@ } &::before { - width: 28px; + width: 20px; height: 3px; } &::after { width: 3px; - height: 28px; + height: 20px; } &:hover::before, diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx index ebb9cd78..69a5058b 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC = ({ const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight }); constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight }; + useEffect(() => { + if (!dragRef.current && wrapperRef.current) { + widthRef.current = initialWidth; + heightRef.current = initialHeight; + wrapperRef.current.style.width = `${initialWidth}px`; + wrapperRef.current.style.height = `${initialHeight}px`; + } + }, [initialWidth, initialHeight]); + const handleMouseMove = useRef((e: MouseEvent) => { const drag = dragRef.current; if (!drag || !wrapperRef.current) return; diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index 021f4f3a..176f14ad 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) { {embedUrl ? (
diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css index d326ee5a..987ec0d7 100644 --- a/apps/client/src/features/editor/components/image/image-view.module.css +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -5,6 +5,9 @@ max-width: 100%; border-radius: 8px; overflow: hidden; +} + +.skeleton { animation: pulse 1.2s ease-in-out infinite; @mixin light { diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx index defb64c4..7ec3e26f 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) { className={clsx( selected && "ProseMirror-selectednode", classes.imageWrapper, + !src && classes.skeleton, alignClass, )} style={{ diff --git a/apps/client/src/features/editor/components/pdf/pdf-menu.tsx b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx new file mode 100644 index 00000000..2104bfbc --- /dev/null +++ b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx @@ -0,0 +1,145 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; +import { useCallback } from "react"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { + EditorMenuProps, + ShouldShowProps, +} from "@/features/editor/components/table/types/types.ts"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { + IconPaperclip, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "../common/toolbar-menu.module.css"; + +export function PdfMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) { + return null; + } + + const pdfAttrs = ctx.editor.getAttributes("pdf"); + + return { + isPdf: ctx.editor.isActive("pdf"), + src: pdfAttrs?.src || null, + name: pdfAttrs?.name || null, + attachmentId: pdfAttrs?.attachmentId || null, + }; + }, + }); + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state || !editor.isActive("pdf")) { + return false; + } + + const { selection } = state; + const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null; + if (!dom) return false; + + return !!dom.querySelector("[data-pdf-error]"); + }, + [editor], + ); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "pdf"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const handleConvertToAttachment = useCallback(() => { + if (!editorState?.src) return; + + const { selection } = editor.state; + const { from } = selection; + const node = editor.state.doc.nodeAt(from); + if (!node || node.type.name !== "pdf") return; + + editor + .chain() + .insertContentAt( + { from, to: from + node.nodeSize }, + { + type: "attachment", + attrs: { + url: node.attrs.src, + name: node.attrs.name, + attachmentId: node.attrs.attachmentId, + size: node.attrs.size, + mime: "application/pdf", + }, + }, + ) + .run(); + }, [editor, editorState]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + return ( + +
+ + + + + + + + + + + +
+
+ ); +} + +export default PdfMenu; diff --git a/apps/client/src/features/editor/components/pdf/pdf-view.module.css b/apps/client/src/features/editor/components/pdf/pdf-view.module.css new file mode 100644 index 00000000..df5af87f --- /dev/null +++ b/apps/client/src/features/editor/components/pdf/pdf-view.module.css @@ -0,0 +1,100 @@ +.pdfWrapper { + display: flex; + justify-content: center; + align-items: center; + max-width: 100%; + border-radius: 8px; + overflow: hidden; +} + +.skeleton { + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 0%; + } + } +} + +.pdfContainer { + display: flex; + justify-content: center; +} + +.pdfResizeWrapper { + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} + +.pdfIframe { + width: 100%; + height: 100%; + border: none; + border-radius: 8px; +} + +.hoverMenu { + position: absolute; + top: 56px; + right: 8px; + z-index: 2; + display: flex; + gap: 4px; + padding: 4px; + border-radius: 6px; + opacity: 0; + transition: opacity 0.15s ease; + background-color: rgba(0, 0, 0, 0.5); +} + +.hoverMenu::before { + content: ""; + position: absolute; + inset: -12px; +} + +.hoverMenu:hover { + opacity: 1; +} + +.pdfResizeWrapper:hover .hoverMenu { + opacity: 1; +} + +.pdfError { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 32px; + border-radius: 8px; + cursor: pointer; + + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} diff --git a/apps/client/src/features/editor/components/pdf/pdf-view.tsx b/apps/client/src/features/editor/components/pdf/pdf-view.tsx new file mode 100644 index 00000000..6207da9f --- /dev/null +++ b/apps/client/src/features/editor/components/pdf/pdf-view.tsx @@ -0,0 +1,168 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core"; +import { useCallback, useMemo, useState } from "react"; +import { getFileUrl } from "@/lib/config.ts"; +import { ResizableWrapper } from "../common/resizable-wrapper"; +import clsx from "clsx"; +import classes from "./pdf-view.module.css"; +import { useTranslation } from "react-i18next"; +import { isInternalFileUrl } from "@docmost/editor-ext"; +import { + IconFileTypePdf, + IconPaperclip, + IconTrash, +} from "@tabler/icons-react"; + +export default function PdfView(props: NodeViewProps) { + const { t } = useTranslation(); + const { editor, node, getPos, selected, updateAttributes } = props; + const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs; + const [hasError, setHasError] = useState(false); + + const safeSrc = useMemo(() => { + if (!src || !isInternalFileUrl(src)) return null; + return getFileUrl(src); + }, [src]); + + const handleSelect = useCallback(() => { + const pos = getPos(); + if (pos !== undefined) { + editor.commands.setNodeSelection(pos); + } + }, [editor, getPos]); + + const handleResize = useCallback( + (newWidth: number, newHeight: number) => { + updateAttributes({ width: newWidth, height: newHeight }); + }, + [updateAttributes], + ); + + const handleConvertToAttachment = useCallback(() => { + if (!src) return; + const pos = getPos(); + if (pos === undefined) return; + const currentNode = editor.state.doc.nodeAt(pos); + if (!currentNode || currentNode.type.name !== "pdf") return; + + editor + .chain() + .insertContentAt( + { from: pos, to: pos + currentNode.nodeSize }, + { + type: "attachment", + attrs: { + url: currentNode.attrs.src, + name: currentNode.attrs.name, + attachmentId: currentNode.attrs.attachmentId, + size: currentNode.attrs.size, + mime: "application/pdf", + }, + }, + ) + .run(); + }, [editor, src, getPos]); + + const handleDelete = useCallback(() => { + const pos = getPos(); + if (pos === undefined) return; + editor.commands.setNodeSelection(pos); + editor.commands.deleteSelection(); + }, [editor, getPos]); + + if (!src || !safeSrc) { + return ( + +
+ + + + {placeholder?.name + ? t("Uploading {{name}}", { name: placeholder.name }) + : t("Uploading file")} + + +
+
+ ); + } + + if (hasError) { + return ( + +
+ + + {t("Failed to load PDF")} + +
+
+ ); + } + + return ( + +
+ +