mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 07:13:06 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f90d6474 | |||
| 3c4cab0d2a | |||
| 4de25a8b94 | |||
| cf5bbb10df | |||
| ac17521717 | |||
| 29d3a8cee2 | |||
| 66099f4657 | |||
| cefabc8683 | |||
| 703bfad424 | |||
| b82171c24c | |||
| 1baff07e4e | |||
| f689291a99 | |||
| 0e4af65935 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.23.1",
|
||||
"version": "0.23.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy anchor link": "Copy anchor link",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
@@ -404,6 +405,7 @@
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Anchor link copied": "Anchor link copied",
|
||||
"Page duplicated successfully": "Page duplicated successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
@@ -533,8 +535,5 @@
|
||||
"Remove image": "Remove image",
|
||||
"Failed to remove image": "Failed to remove image",
|
||||
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
|
||||
"Image removed successfully": "Image removed successfully",
|
||||
"Added successfully": "Added successfully",
|
||||
"Removed successfully": "Removed successfully",
|
||||
"Failed to add group members": "Failed to add group members"
|
||||
"Image removed successfully": "Image removed successfully"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
|
||||
export const handlePaste = (
|
||||
@@ -34,7 +33,9 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
createMentionAction(url, view, pos, creatorId);
|
||||
const anchor = match[6]; // Extract anchor from the regex match
|
||||
const urlWithoutAnchor = anchor ? url.substring(0, url.indexOf("#")) : url;
|
||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.anchorScrollMargin {
|
||||
scroll-margin-top: 95px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export function useAnchorScroll(offset = 95, maxRetries = 10, retryDelay = 500) {
|
||||
const location = useLocation();
|
||||
const lastHash = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
let retries = maxRetries;
|
||||
|
||||
const tryScroll = () => {
|
||||
let el = document.getElementById(lastHash.current);
|
||||
|
||||
if (!el) {
|
||||
const hash = lastHash.current;
|
||||
|
||||
if (hash.includes('-')) {
|
||||
const parts = hash.split('-');
|
||||
const possibleUid = parts[parts.length - 1];
|
||||
|
||||
const elements = document.querySelectorAll('[id]');
|
||||
for (const element of elements) {
|
||||
if (element.id.endsWith(`-${possibleUid}`)) {
|
||||
el = element as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
const elements = document.querySelectorAll('[id]');
|
||||
for (const element of elements) {
|
||||
if (element.id.endsWith(`-${hash}`) || element.id === hash) {
|
||||
el = element as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top: y, behavior: "smooth" });
|
||||
window.history.replaceState(null, "", `#${el.id}`);
|
||||
} else if (retries > 0) {
|
||||
retries--;
|
||||
setTimeout(tryScroll, retryDelay);
|
||||
}
|
||||
};
|
||||
|
||||
if (location.hash) {
|
||||
lastHash.current = location.hash.slice(1);
|
||||
tryScroll();
|
||||
}
|
||||
}, [location, offset, maxRetries, retryDelay]);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type LinkFn = (
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
creatorId: string,
|
||||
anchor?: string,
|
||||
) => void;
|
||||
|
||||
export interface InternalLinkOptions {
|
||||
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
|
||||
|
||||
export const handleInternalLink =
|
||||
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
||||
async (url: string, view, pos, creatorId) => {
|
||||
async (url: string, view, pos, creatorId, anchor) => {
|
||||
const validated = validateFn(url, view);
|
||||
if (!validated) return;
|
||||
|
||||
@@ -35,6 +36,7 @@ export const handleInternalLink =
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
creatorId: creatorId,
|
||||
anchor: anchor,
|
||||
});
|
||||
|
||||
if (!node) return;
|
||||
|
||||
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId } = node.attrs;
|
||||
const { label, entityType, entityId, slugId, anchor } = node.attrs;
|
||||
const { spaceSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const {
|
||||
@@ -27,6 +27,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: label,
|
||||
anchor,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchor)
|
||||
}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
HeadingAnchors,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -78,6 +79,8 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { countWords } from "alfaaz";
|
||||
import UniqueID from "@tiptap/extension-unique-id";
|
||||
import { generateEditorNodeId } from "../utils/nanoid";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -94,6 +97,7 @@ lowlight.register("scala", scala);
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
heading: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
color: "#70CFF8",
|
||||
@@ -105,6 +109,7 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
HeadingAnchors,
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
@@ -241,6 +246,12 @@ export const mainExtensions = [
|
||||
}
|
||||
},
|
||||
}).configure(),
|
||||
UniqueID.configure({
|
||||
types: ["heading"],
|
||||
attributeName: "nodeId",
|
||||
generateID: () => generateEditorNodeId(),
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@@ -51,6 +51,7 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from '@/features/search/constants.ts';
|
||||
import { useAnchorScroll } from "./components/heading/use-anchor-scroll";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -87,6 +88,7 @@ export default function PageEditor({
|
||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
// useAnchorScroll();
|
||||
const userPageEditMode =
|
||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
.heading-block {
|
||||
position: relative;
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
.has-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heading-anchor-wrapper {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.heading-anchor-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-gray-5);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.has-anchor:hover .heading-anchor-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.heading-anchor-button:hover {
|
||||
color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
.heading-anchor-button.copied {
|
||||
color: var(--mantine-color-green-6);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.heading-anchor-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.heading-anchor-button {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.has-anchor:hover .heading-anchor-button {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.heading-anchor-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror .heading-anchor-button {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Hide button when cursor is in the same heading */
|
||||
.ProseMirror-focused .has-anchor.ProseMirror-selectednode .heading-anchor-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Always show on hover, regardless of focus state */
|
||||
.has-anchor:hover .heading-anchor-button {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
@@ -12,3 +12,4 @@
|
||||
@import "./find.css";
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./heading-anchors.css";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const slugIdAlphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
export const generateEditorNodeId = customAlphabet(slugIdAlphabet, 12);
|
||||
@@ -37,7 +37,6 @@ export default function AddGroupMemberModal() {
|
||||
<MultiUserSelect
|
||||
label={t("Add group members")}
|
||||
onChange={handleMultiSelectChange}
|
||||
groupId={groupId}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next";
|
||||
interface MultiUserSelectProps {
|
||||
onChange: (value: string[]) => void;
|
||||
label?: string;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
@@ -22,9 +21,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
size={36}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
<Text size="xs" opacity={0.5}>
|
||||
{option?.["email"]}
|
||||
</Text>
|
||||
@@ -32,20 +29,14 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
</Group>
|
||||
);
|
||||
|
||||
export function MultiUserSelect({
|
||||
onChange,
|
||||
label,
|
||||
groupId,
|
||||
}: MultiUserSelectProps) {
|
||||
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||
const { data: users, isLoading } = useWorkspaceMembersQuery({
|
||||
query: debouncedQuery,
|
||||
limit: 50,
|
||||
...(groupId && { groupId }),
|
||||
});
|
||||
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useGetGroupsQuery(
|
||||
params?: QueryParams,
|
||||
@@ -120,24 +119,18 @@ export function useGroupMembersQuery(
|
||||
|
||||
export function useAddGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||
mutationFn: (data) => addGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Added successfully") });
|
||||
notifications.show({ message: "Added successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["workspaceMembers"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to add group members"),
|
||||
message: "Failed to add group members",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
@@ -146,7 +139,6 @@ export function useAddGroupMemberMutation() {
|
||||
|
||||
export function useRemoveGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
@@ -158,15 +150,10 @@ export function useRemoveGroupMemberMutation() {
|
||||
>({
|
||||
mutationFn: (data) => removeGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Removed successfully") });
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["workspaceMembers"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
|
||||
@@ -15,22 +15,29 @@ export const buildPageUrl = (
|
||||
spaceName: string,
|
||||
pageSlugId: string,
|
||||
pageTitle?: string,
|
||||
anchor?: string,
|
||||
): string => {
|
||||
let url: string;
|
||||
if (spaceName === undefined) {
|
||||
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return anchor ? `${url}#${anchor}` : url;
|
||||
};
|
||||
|
||||
export const buildSharedPageUrl = (opts: {
|
||||
shareId: string;
|
||||
pageSlugId: string;
|
||||
pageTitle?: string;
|
||||
anchor?: string;
|
||||
}): string => {
|
||||
const { shareId, pageSlugId, pageTitle } = opts;
|
||||
const { shareId, pageSlugId, pageTitle, anchor } = opts;
|
||||
let url: string;
|
||||
if (!shareId) {
|
||||
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
|
||||
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return anchor ? `${url}#${anchor}` : url;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function AddSpaceMembersModal({
|
||||
<Divider size="xs" mb="xs" />
|
||||
|
||||
<Stack>
|
||||
<MultiMemberSelect onChange={handleMultiSelectChange} spaceId={spaceId} />
|
||||
<MultiMemberSelect onChange={handleMultiSelectChange} />
|
||||
<SpaceMemberRole
|
||||
onSelect={handleRoleSelection}
|
||||
defaultRole={role}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MultiMemberSelectProps {
|
||||
onChange: (value: string[]) => void;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
@@ -26,38 +25,23 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
)}
|
||||
{option["type"] === "group" && <IconGroupCircle />}
|
||||
<div>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
||||
{option["type"] === "user" && option["email"] && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{option["email"]}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export function MultiMemberSelect({
|
||||
onChange,
|
||||
spaceId,
|
||||
}: MultiMemberSelectProps) {
|
||||
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||
|
||||
console.log("vacant", spaceId);
|
||||
|
||||
// Filter out empty parameters to avoid duplicate cache keys
|
||||
const queryParams = {
|
||||
...(debouncedQuery && { query: debouncedQuery }),
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: debouncedQuery,
|
||||
includeUsers: true,
|
||||
includeGroups: true,
|
||||
...(spaceId && { spaceId }),
|
||||
};
|
||||
|
||||
const { data: suggestion, isLoading } =
|
||||
useSearchSuggestionsQuery(queryParams);
|
||||
});
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,14 +63,14 @@ export function MultiMemberSelect({
|
||||
|
||||
// Create fresh data structure based on current search results
|
||||
const newData = [];
|
||||
|
||||
|
||||
if (userItems && userItems.length > 0) {
|
||||
newData.push({
|
||||
group: t("Select a user"),
|
||||
items: userItems,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (groupItems && groupItems.length > 0) {
|
||||
newData.push({
|
||||
group: t("Select a group"),
|
||||
|
||||
@@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
const spaces = queryClient.getQueryData(["spaces"]) as any;
|
||||
// Remove space-specific queries
|
||||
if (variables.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.id],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Invalidate recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes"],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", variables.id],
|
||||
});
|
||||
}
|
||||
|
||||
// Update spaces list cache
|
||||
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
|
||||
if (spaces) {
|
||||
spaces.items = spaces.items?.filter(
|
||||
(space: ISpace) => space.id !== variables.id,
|
||||
);
|
||||
queryClient.setQueryData(["spaces"], spaces);
|
||||
}
|
||||
}*/
|
||||
|
||||
// Invalidate all spaces queries to refresh lists
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
@@ -190,28 +213,6 @@ export function useAddSpaceMemberMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
|
||||
// Optimistically update search suggestions cache by filtering out added users and groups
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["search-suggestion"], exact: false },
|
||||
(oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
const filteredUsers = oldData.users?.filter((user: any) =>
|
||||
!variables.userIds?.includes(user.id)
|
||||
) || [];
|
||||
|
||||
const filteredGroups = oldData.groups?.filter((group: any) =>
|
||||
!variables.groupIds?.includes(group.id)
|
||||
) || [];
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
users: filteredUsers,
|
||||
groups: filteredGroups,
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
@@ -231,16 +232,6 @@ export function useRemoveSpaceMemberMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["spaceMembers", variables.spaceId],
|
||||
});
|
||||
|
||||
// For remove operations, invalidate to get fresh data
|
||||
// since adding the user/group back requires fetching their current data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["search-suggestion"],
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as any[];
|
||||
return queryKey[1]?.spaceId === variables.spaceId;
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const INTERNAL_LINK_REGEX =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/;
|
||||
|
||||
export const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
export const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
//export const INTERNAL_LINK_REGEX =
|
||||
// /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
@@ -2,7 +2,6 @@ export interface QueryParams {
|
||||
query?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
|
||||
@@ -8,12 +8,14 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||
import { useAnchorScroll } from "@/features/editor/components/heading/use-anchor-scroll";
|
||||
|
||||
export default function SharedPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
useAnchorScroll();
|
||||
|
||||
const { data, isLoading, isError, error } = useSharePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.23.1",
|
||||
"version": "0.23.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 UniqueID from '@tiptap/extension-unique-id';
|
||||
import {
|
||||
Callout,
|
||||
Comment,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
HeadingAnchors
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
@@ -45,7 +47,9 @@ import { Node } from '@tiptap/pm/model';
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
heading: false,
|
||||
}),
|
||||
HeadingAnchors,
|
||||
Comment,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TaskList,
|
||||
@@ -81,6 +85,10 @@ export const tiptapExtensions = [
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
UniqueID.configure({
|
||||
types: ['heading'],
|
||||
attributeName: 'nodeId',
|
||||
}),
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -145,7 +145,7 @@ export class SearchService {
|
||||
const query = suggestion.query.toLowerCase().trim();
|
||||
|
||||
if (suggestion.includeUsers) {
|
||||
let userQuery = this.db
|
||||
const userQuery = this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email', 'avatarUrl'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
@@ -159,25 +159,14 @@ export class SearchService {
|
||||
),
|
||||
eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`),
|
||||
]),
|
||||
);
|
||||
)
|
||||
.limit(limit);
|
||||
|
||||
// Filter out users who are already members of the space
|
||||
if (suggestion.spaceId) {
|
||||
userQuery = userQuery.where('users.id', 'not in', (eb) =>
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('spaceId', '=', suggestion.spaceId)
|
||||
.where('userId', 'is not', null),
|
||||
);
|
||||
}
|
||||
|
||||
userQuery = userQuery.limit(limit);
|
||||
users = await userQuery.execute();
|
||||
}
|
||||
|
||||
if (suggestion.includeGroups) {
|
||||
let groupQuery = this.db
|
||||
groups = await this.db
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name', 'description'])
|
||||
.where((eb) =>
|
||||
@@ -187,21 +176,9 @@ export class SearchService {
|
||||
sql`LOWER(f_unaccent(${`%${query}%`}))`,
|
||||
),
|
||||
)
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
// Filter out groups that are already members of the space
|
||||
if (suggestion.spaceId) {
|
||||
groupQuery = groupQuery.where('groups.id', 'not in', (eb) =>
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.select('groupId')
|
||||
.where('spaceId', '=', suggestion.spaceId)
|
||||
.where('groupId', 'is not', null),
|
||||
);
|
||||
}
|
||||
|
||||
groupQuery = groupQuery.limit(limit);
|
||||
groups = await groupQuery.execute();
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (suggestion.includePages) {
|
||||
|
||||
@@ -23,8 +23,4 @@ export class PaginationOptions {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
@@ -162,15 +162,6 @@ export class UserRepo {
|
||||
);
|
||||
}
|
||||
|
||||
if (pagination.groupId) {
|
||||
query = query.where('users.id', 'not in', (eb) =>
|
||||
eb
|
||||
.selectFrom('groupUsers')
|
||||
.select('userId')
|
||||
.where('groupId', '=', pagination.groupId),
|
||||
);
|
||||
}
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
page: pagination.page,
|
||||
perPage: pagination.limit,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: bcc077ae3b...3af21def15
@@ -222,17 +222,40 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
}
|
||||
|
||||
export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) {
|
||||
// find the nearest <p> or <a> ancestor
|
||||
let $wrapper = $node.closest('p, a');
|
||||
// Keep track of processed wrappers to avoid infinite loops
|
||||
const processedWrappers = new Set<any>();
|
||||
|
||||
let $wrapper = $node.closest('p, a');
|
||||
while ($wrapper.length) {
|
||||
// if the wrapper has only our node inside, replace it entirely
|
||||
if ($wrapper.contents().length === 1) {
|
||||
const wrapperElement = $wrapper.get(0);
|
||||
|
||||
// If we've already processed this wrapper, break to avoid infinite loop
|
||||
if (processedWrappers.has(wrapperElement)) {
|
||||
break;
|
||||
}
|
||||
|
||||
processedWrappers.add(wrapperElement);
|
||||
|
||||
// Check if the wrapper contains only whitespace and our target node
|
||||
const hasOnlyTargetNode =
|
||||
$wrapper.contents().filter((_, el) => {
|
||||
const $el = $(el);
|
||||
// Skip whitespace-only text nodes. NodeType 3 = text node
|
||||
if (el.nodeType === 3 && !$el.text().trim()) {
|
||||
return false;
|
||||
}
|
||||
// Return true if this is not our target node
|
||||
return !$el.is($node) && !$node.is($el);
|
||||
}).length === 0;
|
||||
|
||||
if (hasOnlyTargetNode) {
|
||||
// Replace the wrapper entirely with our node
|
||||
$wrapper.replaceWith($node);
|
||||
} else {
|
||||
// otherwise just move the node to before the wrapper
|
||||
// Move the node to before the wrapper, preserving other content
|
||||
$wrapper.before($node);
|
||||
}
|
||||
|
||||
// look again for any new wrapper around $node
|
||||
$wrapper = $node.closest('p, a');
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.23.1",
|
||||
"version": "0.23.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@tiptap/extension-text-style": "^2.10.3",
|
||||
"@tiptap/extension-typography": "^2.10.3",
|
||||
"@tiptap/extension-underline": "^2.10.3",
|
||||
"@tiptap/extension-unique-id": "^2.23.0",
|
||||
"@tiptap/extension-youtube": "^2.10.3",
|
||||
"@tiptap/html": "^2.10.3",
|
||||
"@tiptap/pm": "^2.10.3",
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
export * from "./lib/heading";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { buildAnchorDecorations } from './utils';
|
||||
|
||||
const HEADING_ANCHORS_PLUGIN_KEY = new PluginKey("heading-anchors");
|
||||
export const HeadingAnchors = Heading.extend({
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const hasLevel = this.options.levels.includes(node.attrs.level);
|
||||
const level = hasLevel ? node.attrs.level : this.options.levels[0];
|
||||
|
||||
return [
|
||||
`h${level}`,
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
class: "heading-block",
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
new Plugin({
|
||||
key: HEADING_ANCHORS_PLUGIN_KEY,
|
||||
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return buildAnchorDecorations(doc);
|
||||
},
|
||||
|
||||
apply(tr, oldState, _, newState) {
|
||||
if (!tr.docChanged) {
|
||||
return oldState.map(tr.mapping, tr.doc);
|
||||
}
|
||||
|
||||
let headingsChanged = false;
|
||||
tr.steps.forEach((step) => {
|
||||
step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
|
||||
// Check both old and new document ranges for headings
|
||||
const checkRange = (
|
||||
doc: ProseMirrorNode,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
if (node.type.name === 'heading') {
|
||||
headingsChanged = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (tr.docs[0]) {
|
||||
checkRange(tr.docs[0], oldStart, oldEnd);
|
||||
}
|
||||
checkRange(newState.doc, newStart, newEnd);
|
||||
});
|
||||
});
|
||||
|
||||
if (headingsChanged) {
|
||||
return buildAnchorDecorations(newState.doc);
|
||||
}
|
||||
|
||||
return oldState.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default HeadingAnchors;
|
||||
@@ -0,0 +1 @@
|
||||
export { HeadingAnchors } from "./heading-anchors";
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
const textToSlug = (text: string): string => {
|
||||
return slugify(text?.substring(0, 20));
|
||||
};
|
||||
|
||||
function buildAnchorId(node: ProseMirrorNode): string {
|
||||
const text = node.textContent;
|
||||
const nodeId = node.attrs.nodeId;
|
||||
|
||||
if (!text) return "";
|
||||
|
||||
if (nodeId) {
|
||||
const slug = textToSlug(text);
|
||||
return slug ? `${slug}-${nodeId}` : nodeId;
|
||||
}
|
||||
|
||||
return textToSlug(text);
|
||||
}
|
||||
|
||||
function createAnchorLink(id: string): HTMLElement {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "heading-anchor-wrapper";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "heading-anchor-button";
|
||||
button.setAttribute("aria-label", "Copy link to this section");
|
||||
button.setAttribute("contenteditable", "false");
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
button.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = id;
|
||||
|
||||
navigator.clipboard.writeText(url.toString()).then(() => {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
|
||||
</svg>
|
||||
`;
|
||||
button.classList.add("copied");
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove("copied");
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.appendChild(button);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
export function buildAnchorDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== "heading" || !node.textContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorId = buildAnchorId(node);
|
||||
if (!anchorId) return;
|
||||
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
id: anchorId,
|
||||
class: "has-anchor",
|
||||
"data-anchor-id": anchorId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (node.content.size > 0) {
|
||||
const lastChildEnd = pos + 1 + node.content.size;
|
||||
decorations.push(
|
||||
Decoration.widget(lastChildEnd, createAnchorLink(anchorId), {
|
||||
side: 0,
|
||||
key: `anchor-${anchorId}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
@@ -33,6 +33,11 @@ export interface MentionNodeAttrs {
|
||||
* the id of the user who initiated the mention
|
||||
*/
|
||||
creatorId?: string;
|
||||
|
||||
/**
|
||||
* the anchor hash for page mentions (e.g., "heading-1")
|
||||
*/
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
export type MentionOptions<
|
||||
@@ -246,6 +251,20 @@ export const Mention = Node.create<MentionOptions>({
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
anchor: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-anchor"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.anchor) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-anchor": attributes.anchor,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Generated
+21
@@ -130,6 +130,9 @@ importers:
|
||||
'@tiptap/extension-underline':
|
||||
specifier: ^2.10.3
|
||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
|
||||
'@tiptap/extension-unique-id':
|
||||
specifier: ^2.23.0
|
||||
version: 2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||
'@tiptap/extension-youtube':
|
||||
specifier: ^2.10.3
|
||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))
|
||||
@@ -4257,6 +4260,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-unique-id@2.25.0':
|
||||
resolution: {integrity: sha512-D45xSQ6H4v5agVCnv6l/TGQt4coDSo+Xbg2/CrP8UNYomVbPNFDmtDHL4Tyoq5HAa9HpMskVpWmJAmNJUH6f9A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-youtube@2.14.0':
|
||||
resolution: {integrity: sha512-kryHjsjlIV2B6rS0Mnv9AqAyCCaeNWE1XDAWyYfhWQSmQkfaxSZU3rMnh3BMvSsVsdv5mtyxyBqBTrQA2sBSaw==}
|
||||
peerDependencies:
|
||||
@@ -9581,6 +9590,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
@@ -14287,6 +14300,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||
|
||||
'@tiptap/extension-unique-id@2.25.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||
'@tiptap/pm': 2.14.0
|
||||
uuid: 10.0.0
|
||||
|
||||
'@tiptap/extension-youtube@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||
@@ -20591,6 +20610,8 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user