mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 20:54:07 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4fa981d80 | |||
| 66c26af34b |
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
|
|||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
|
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import {
|
import {
|
||||||
buildPageUrl,
|
buildPageUrl,
|
||||||
buildSharedPageUrl,
|
buildSharedPageUrl,
|
||||||
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
|
|||||||
export default function MentionView(props: NodeViewProps) {
|
export default function MentionView(props: NodeViewProps) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||||
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const isShareRoute = location.pathname.startsWith("/share");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||||
|
|
||||||
const location = useLocation();
|
const { data: sharedPage } = useSharePageQuery({
|
||||||
const isShareRoute = location.pathname.startsWith("/share");
|
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const currentPageSlugId = extractPageSlugId(pageSlug);
|
const currentPageSlugId = extractPageSlugId(pageSlug);
|
||||||
const isSamePage = currentPageSlugId === slugId;
|
const isSamePage = currentPageSlugId === slugId;
|
||||||
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sharePageTitle = sharedPage?.page?.title || label;
|
||||||
|
|
||||||
const shareSlugUrl = buildSharedPageUrl({
|
const shareSlugUrl = buildSharedPageUrl({
|
||||||
shareId,
|
shareId,
|
||||||
pageSlugId: slugId,
|
pageSlugId: slugId,
|
||||||
pageTitle: label,
|
pageTitle: sharePageTitle,
|
||||||
anchorId,
|
anchorId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entityType === "page" && isError && (
|
{isPageMention && isShareRoute && (
|
||||||
<Text component="span" c="dimmed" size="sm">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entityType === "page" && !isError && (
|
|
||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={
|
to={shareSlugUrl}
|
||||||
isShareRoute
|
onClick={handleClick}
|
||||||
? shareSlugUrl
|
underline="never"
|
||||||
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
|
className={classes.pageMentionLink}
|
||||||
}
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
component="span"
|
||||||
|
size={18}
|
||||||
|
style={{ verticalAlign: "text-bottom" }}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<span className={classes.pageMentionText}>
|
||||||
|
{sharePageTitle}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPageMention && !isShareRoute && isError && (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
|
||||||
|
onClick={handleClick}
|
||||||
|
underline="never"
|
||||||
|
className={classes.pageMentionLink}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
component="span"
|
||||||
|
size={18}
|
||||||
|
style={{ verticalAlign: "text-bottom" }}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<span className={classes.pageMentionText}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPageMention && !isShareRoute && !isError && (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
fw={500}
|
||||||
|
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||||
|
|
||||||
const { data, isLoading, error } = useGetSidebarPagesQuery({
|
const { data, isLoading, error } = useGetSidebarPagesQuery(
|
||||||
pageId: currentPageId,
|
shareId ? null : { pageId: currentPageId },
|
||||||
});
|
);
|
||||||
|
|
||||||
const subpages = useMemo(() => {
|
const subpages = useMemo(() => {
|
||||||
// If we're in a shared context, use the shared subpages
|
// If we're in a shared context, use the shared subpages
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
|||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
|
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +48,7 @@ try {
|
|||||||
middleware: { mount: true },
|
middleware: { mount: true },
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
|
NoopAuditModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
|||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import {
|
|
||||||
AUDIT_SERVICE,
|
|
||||||
NoopAuditService,
|
|
||||||
} from '../integrations/audit/audit.service';
|
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
],
|
],
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUDIT_SERVICE,
|
|
||||||
useClass: NoopAuditService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [AUDIT_SERVICE],
|
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AUDIT_SERVICE,
|
||||||
|
useClass: NoopAuditService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AUDIT_SERVICE],
|
||||||
|
})
|
||||||
|
export class NoopAuditModule {}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import {
|
import { normalizeFileUrl } from "./media-utils";
|
||||||
normalizeFileUrl,
|
|
||||||
applyAlignment,
|
|
||||||
createPlaceholderView,
|
|
||||||
setupMediaLoading,
|
|
||||||
} from "./media-utils";
|
|
||||||
|
|
||||||
export type DrawioResizeOptions = {
|
export type DrawioResizeOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -210,7 +205,22 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
const { node, getPos, HTMLAttributes, editor } = props;
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
if (!node.attrs.src) {
|
if (!node.attrs.src) {
|
||||||
return createPlaceholderView(this.options.view, props);
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.createElement("img");
|
const el = document.createElement("img");
|
||||||
@@ -281,10 +291,54 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show skeleton background while image loads from server
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
dom.style.background =
|
||||||
|
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||||
|
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
dom.style.background = "";
|
||||||
|
};
|
||||||
|
|
||||||
return nodeView;
|
return nodeView;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import {
|
import { normalizeFileUrl } from "./media-utils";
|
||||||
normalizeFileUrl,
|
|
||||||
applyAlignment,
|
|
||||||
createPlaceholderView,
|
|
||||||
setupMediaLoading,
|
|
||||||
} from "./media-utils";
|
|
||||||
|
|
||||||
export type ExcalidrawResizeOptions = {
|
export type ExcalidrawResizeOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -210,7 +205,22 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
const { node, getPos, HTMLAttributes, editor } = props;
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
if (!node.attrs.src) {
|
if (!node.attrs.src) {
|
||||||
return createPlaceholderView(this.options.view, props);
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.createElement("img");
|
const el = document.createElement("img");
|
||||||
@@ -281,10 +291,54 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show skeleton background while image loads from server
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
dom.style.background =
|
||||||
|
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||||
|
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
dom.style.background = "";
|
||||||
|
};
|
||||||
|
|
||||||
return nodeView;
|
return nodeView;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import {
|
|||||||
Range,
|
Range,
|
||||||
ResizableNodeView,
|
ResizableNodeView,
|
||||||
} from "@tiptap/core";
|
} from "@tiptap/core";
|
||||||
import {
|
import { normalizeFileUrl } from "../media-utils";
|
||||||
normalizeFileUrl,
|
|
||||||
applyAlignment,
|
|
||||||
createPlaceholderView,
|
|
||||||
setupMediaLoading,
|
|
||||||
} from "../media-utils";
|
|
||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
export type ImageResizeOptions = {
|
export type ImageResizeOptions = {
|
||||||
@@ -221,8 +216,25 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
return (props) => {
|
return (props) => {
|
||||||
const { node, getPos, HTMLAttributes, editor } = props;
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
// If no src yet (placeholder/uploading), use React view for loading UI
|
||||||
if (!HTMLAttributes.src) {
|
if (!HTMLAttributes.src) {
|
||||||
return createPlaceholderView(this.options.view, props);
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
// When the node gets a src, return false from update to force rebuild
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has src — use ResizableNodeView
|
// Has src — use ResizableNodeView
|
||||||
@@ -319,10 +331,56 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupMediaLoading(nodeView.dom as HTMLElement, el, node);
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
// Apply initial alignment
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
// Defer conversion until we can measure the container
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show skeleton background while image loads from server
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
dom.style.background =
|
||||||
|
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||||
|
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
dom.style.background = "";
|
||||||
|
};
|
||||||
|
|
||||||
return nodeView;
|
return nodeView;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
|
|
||||||
export function normalizeFileUrl(src: string): string {
|
export function normalizeFileUrl(src: string): string {
|
||||||
if (src && src.startsWith("/files/")) {
|
if (src && src.startsWith("/files/")) {
|
||||||
@@ -8,78 +7,6 @@ export function normalizeFileUrl(src: string): string {
|
|||||||
return src || "";
|
return src || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAlignment(container: HTMLElement, align: string) {
|
|
||||||
if (align === "left") {
|
|
||||||
container.style.justifyContent = "flex-start";
|
|
||||||
} else if (align === "right") {
|
|
||||||
container.style.justifyContent = "flex-end";
|
|
||||||
} else {
|
|
||||||
container.style.justifyContent = "center";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPlaceholderView(viewComponent: any, props: any) {
|
|
||||||
const { node, editor } = props;
|
|
||||||
editor.isInitialized = true;
|
|
||||||
const reactView = ReactNodeViewRenderer(viewComponent);
|
|
||||||
const view = reactView(props);
|
|
||||||
|
|
||||||
const originalUpdate = view.update?.bind(view);
|
|
||||||
view.update = (updatedNode: any, decorations: any, innerDecorations: any) => {
|
|
||||||
if (updatedNode.attrs.src && !node.attrs.src) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (originalUpdate) {
|
|
||||||
return originalUpdate(updatedNode, decorations, innerDecorations);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupMediaLoading(
|
|
||||||
dom: HTMLElement,
|
|
||||||
el: HTMLElement,
|
|
||||||
node: any,
|
|
||||||
loadEvent: "load" | "loadedmetadata" = "load",
|
|
||||||
) {
|
|
||||||
applyAlignment(dom, node.attrs.align || "center");
|
|
||||||
|
|
||||||
const widthAttr = node.attrs.width;
|
|
||||||
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const parentEl = dom.parentElement;
|
|
||||||
if (parentEl) {
|
|
||||||
const containerWidth = parentEl.clientWidth;
|
|
||||||
const pctValue = parseInt(widthAttr, 10);
|
|
||||||
if (!isNaN(pctValue) && containerWidth > 0) {
|
|
||||||
const pxWidth = Math.round(containerWidth * (pctValue / 100));
|
|
||||||
el.style.width = `${pxWidth}px`;
|
|
||||||
if (node.attrs.aspectRatio) {
|
|
||||||
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dom.style.visibility = "";
|
|
||||||
dom.style.pointerEvents = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.style.pointerEvents = "none";
|
|
||||||
dom.style.background =
|
|
||||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
|
||||||
|
|
||||||
el.addEventListener(
|
|
||||||
loadEvent,
|
|
||||||
() => {
|
|
||||||
dom.style.pointerEvents = "";
|
|
||||||
dom.style.background = "";
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UploadFn = (
|
export type UploadFn = (
|
||||||
file: File,
|
file: File,
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
import {
|
import { normalizeFileUrl } from "../media-utils";
|
||||||
normalizeFileUrl,
|
|
||||||
applyAlignment,
|
|
||||||
createPlaceholderView,
|
|
||||||
setupMediaLoading,
|
|
||||||
} from "../media-utils";
|
|
||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
export type VideoResizeOptions = {
|
export type VideoResizeOptions = {
|
||||||
@@ -210,7 +205,22 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
const { node, getPos, HTMLAttributes, editor } = props;
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
if (!node.attrs.src) {
|
if (!node.attrs.src) {
|
||||||
return createPlaceholderView(this.options.view, props);
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = document.createElement("video");
|
const el = document.createElement("video");
|
||||||
@@ -289,10 +299,54 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupMediaLoading(nodeView.dom as HTMLElement, el, node, "loadedmetadata");
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show skeleton background while video loads from server
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
dom.style.background =
|
||||||
|
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
|
||||||
|
|
||||||
|
el.onloadedmetadata = () => {
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
dom.style.background = "";
|
||||||
|
};
|
||||||
|
|
||||||
return nodeView;
|
return nodeView;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user