mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23cddb78b7 | ||
|
|
f05a611910 | ||
|
|
5792fc7ca2 | ||
|
|
949072744d | ||
|
|
0a87db4f1f | ||
|
|
89e27a1a07 | ||
|
|
27fa9af959 | ||
|
|
84e1e56cf6 | ||
|
|
6ff8c7cdfe | ||
|
|
ac7935eff9 | ||
|
|
876f3da1b2 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
FROM node:26-slim AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
RUN npm install -g pnpm@11.23.0
|
||||
RUN npm install -g pnpm@11.25.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type FieldProps = {
|
||||
rowId: string;
|
||||
readOnly: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
};
|
||||
|
||||
type FieldShellProps = {
|
||||
@@ -99,9 +100,10 @@ type DetailFieldProps = {
|
||||
row: IBaseRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
onEditingChange: (editing: boolean) => void;
|
||||
};
|
||||
|
||||
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
|
||||
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
|
||||
const descriptor = getDescriptor(property.type);
|
||||
const value = descriptor?.systemAccessor
|
||||
? descriptor.systemAccessor(row)
|
||||
@@ -112,6 +114,7 @@ export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldPr
|
||||
rowId: row.id,
|
||||
readOnly,
|
||||
onChange: (next: unknown) => onUpdate(property.id, next),
|
||||
onEditingChange
|
||||
};
|
||||
|
||||
switch (property.type) {
|
||||
|
||||
@@ -9,7 +9,13 @@ const normalize = (s: string) => {
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
export function FieldLongText({
|
||||
property,
|
||||
value,
|
||||
readOnly,
|
||||
onChange,
|
||||
onEditingChange,
|
||||
}: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
@@ -23,6 +29,7 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
onEditingChange?.(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
@@ -50,7 +57,10 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
|
||||
className={classes.fieldTextarea}
|
||||
classNames={{ input: classes.fieldTextareaInput }}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
onEditingChange?.(true);
|
||||
}}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -11,7 +11,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
const toDraft = (value: unknown) =>
|
||||
typeof value === "number" ? String(value) : "";
|
||||
|
||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
||||
export function FieldNumber({
|
||||
property,
|
||||
value,
|
||||
readOnly,
|
||||
onChange,
|
||||
onEditingChange,
|
||||
}: FieldProps) {
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
const [draft, setDraft] = useState(toDraft(value));
|
||||
@@ -36,6 +42,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
onEditingChange?.(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(toDraft(value));
|
||||
@@ -54,6 +61,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
||||
onFocus={() => {
|
||||
setDraft(toDraft(value));
|
||||
setFocused(true);
|
||||
onEditingChange?.(true);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
|
||||
@@ -5,7 +5,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
export function FieldText({
|
||||
property,
|
||||
value,
|
||||
readOnly,
|
||||
onChange,
|
||||
onEditingChange,
|
||||
}: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
@@ -20,6 +26,7 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
onEditingChange?.(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
@@ -54,7 +61,10 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
className={classes.fieldInput}
|
||||
value={draft}
|
||||
maxLength={1000}
|
||||
onFocus={() => setFocused(true)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
onEditingChange?.(true);
|
||||
}}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ type PropertyRowProps = {
|
||||
onMenuOpenChange: (opened: boolean) => void;
|
||||
onMenuDirtyChange: (dirty: boolean) => void;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
autoFocusValue?: boolean;
|
||||
onAutoFocused?: () => void;
|
||||
};
|
||||
@@ -29,6 +30,7 @@ export function PropertyRow({
|
||||
onMenuOpenChange,
|
||||
onMenuDirtyChange,
|
||||
onUpdate,
|
||||
onEditingChange,
|
||||
autoFocusValue,
|
||||
onAutoFocused,
|
||||
}: PropertyRowProps) {
|
||||
@@ -112,6 +114,7 @@ export function PropertyRow({
|
||||
row={row}
|
||||
readOnly={!canEdit}
|
||||
onUpdate={onUpdate}
|
||||
onEditingChange={onEditingChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ export function RowDetailModal({
|
||||
|
||||
const isSaving = updateRowMutation.isPending;
|
||||
const opened = !!openRowId;
|
||||
const [editingField, setEditingField] = useState(false);
|
||||
|
||||
// One field menu open at a time, mirroring the grid header's semantics.
|
||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||
@@ -90,6 +91,7 @@ export function RowDetailModal({
|
||||
useEffect(() => {
|
||||
setOpenMenuId(null);
|
||||
menuDirtyRef.current = false;
|
||||
setEditingField(false);
|
||||
}, [openRowId]);
|
||||
|
||||
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
|
||||
@@ -293,7 +295,7 @@ export function RowDetailModal({
|
||||
row={row}
|
||||
primaryProperty={primaryProperty}
|
||||
canEdit={canEdit}
|
||||
onClose={onClose}
|
||||
onEditingChange={setEditingField}
|
||||
onCommit={(value) => {
|
||||
if (!primaryProperty) return;
|
||||
updateRowMutation.mutate({
|
||||
@@ -317,6 +319,7 @@ export function RowDetailModal({
|
||||
autoFocusValue={property.id === newPropertyId}
|
||||
onAutoFocused={clearNewProperty}
|
||||
menuOpened={openMenuId === property.id}
|
||||
onEditingChange={setEditingField}
|
||||
onMenuOpenChange={(nextOpened) =>
|
||||
handleMenuOpenChange(property.id, nextOpened)
|
||||
}
|
||||
@@ -367,16 +370,38 @@ export function RowDetailModal({
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classes.kbdHint}>
|
||||
{rowIndex >= 0 && rows.length > 1 && (
|
||||
{editingField ? (
|
||||
<>
|
||||
<kbd className={classes.kbd}>↑</kbd>
|
||||
<kbd className={classes.kbd}>↓</kbd>
|
||||
<span>{t("to navigate")}</span>
|
||||
<span className={classes.kbdGroup}>
|
||||
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
|
||||
<span className={classes.kbdPlus} >+</span>
|
||||
<kbd className={classes.kbd}>Enter</kbd>
|
||||
<span>{t("to save")}</span>
|
||||
</span>
|
||||
|
||||
<span className={classes.kbdSeparator} />
|
||||
|
||||
<span className={classes.kbdGroup}>
|
||||
<kbd className={classes.kbd}>Esc</kbd>
|
||||
<span>{t("to reset")}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{rowIndex >= 0 && rows.length > 1 && (
|
||||
<>
|
||||
<kbd className={classes.kbd}>↑</kbd>
|
||||
<kbd className={classes.kbd}>↓</kbd>
|
||||
<span>{t("to navigate")}</span>
|
||||
<span className={classes.kbdSeparator} />
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<kbd className={classes.kbd}>Esc</kbd>
|
||||
<span>{t("to close")}</span>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
<kbd className={classes.kbd}>Esc</kbd>
|
||||
<span>{t("to close")}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { timeAgo } from "@/lib/time.ts";
|
||||
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
|
||||
primaryProperty: IBaseProperty | undefined;
|
||||
canEdit: boolean;
|
||||
onCommit: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
};
|
||||
|
||||
export function RowDetailTitle({
|
||||
@@ -17,13 +17,24 @@ export function RowDetailTitle({
|
||||
primaryProperty,
|
||||
canEdit,
|
||||
onCommit,
|
||||
onClose,
|
||||
onEditingChange,
|
||||
}: RowDetailTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
const initial = primaryProperty
|
||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||
: "";
|
||||
const [value, setValue] = useState(initial);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const commit = () => {
|
||||
onEditingChange?.(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setValue(initial);
|
||||
return;
|
||||
}
|
||||
if (value !== initial) onCommit(value);
|
||||
};
|
||||
|
||||
// Re-sync when the row changes underneath us (navigation or remote edit).
|
||||
useEffect(() => {
|
||||
@@ -43,18 +54,18 @@ export function RowDetailTitle({
|
||||
aria-label={primaryProperty?.name ?? t("Untitled")}
|
||||
value={value}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
onBlur={() => {
|
||||
if (value !== initial) onCommit(value);
|
||||
onFocus={() => {
|
||||
onEditingChange?.(true);
|
||||
}}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
onClose();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -416,9 +416,25 @@
|
||||
}
|
||||
|
||||
.kbdHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kbdGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.kbdPlus {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.kbdSeparator {
|
||||
|
||||
+6
-4
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerm("");
|
||||
editor.commands.setSearchTerms([""]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.setSearchTerms([searchText]);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
}, [searchText]);
|
||||
@@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
closeDialog();
|
||||
}, [location]);
|
||||
if (pageFindState.isOpen) {
|
||||
closeDialog();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import classes from "./search-replace.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
interface SearchNavigationDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
interface SearchNavigationEvent extends CustomEvent {
|
||||
detail: {
|
||||
searchTerms: string[];
|
||||
wholeWord?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openRef = useRef(false);
|
||||
const [resultState, setResultState] = useState({
|
||||
resultIndex: 0,
|
||||
resultsLength: 0,
|
||||
});
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position = results[resultIndex];
|
||||
|
||||
setResultState({
|
||||
resultsLength: results.length,
|
||||
resultIndex,
|
||||
});
|
||||
|
||||
if (!position) return;
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(".search-result-current")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (!openRef.current) return;
|
||||
|
||||
openRef.current = false;
|
||||
setOpen(false);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerms([""]);
|
||||
}
|
||||
const nextParams = new URLSearchParams(location.search);
|
||||
nextParams.delete("q");
|
||||
nextParams.delete("m");
|
||||
const nextSearch = nextParams.toString();
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: nextSearch ? `?${nextSearch}` : "",
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [editor, location.hash, location.pathname, location.search, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (event: Event) => {
|
||||
const { searchTerms: terms, wholeWord = true } = (
|
||||
event as SearchNavigationEvent
|
||||
).detail;
|
||||
|
||||
if (!terms?.length || !isEditorReady(editor)) return;
|
||||
|
||||
openRef.current = false;
|
||||
editor.commands.setSearchTerms(terms);
|
||||
editor.commands.setWholeWord(wholeWord);
|
||||
editor.commands.resetIndex();
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
openRef.current = true;
|
||||
if (results.length === 0) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
setResultState({
|
||||
resultIndex,
|
||||
resultsLength: results.length,
|
||||
});
|
||||
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (openRef.current) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("openSearchNavigationDialog", handleOpen);
|
||||
document.addEventListener("openFindDialogFromEditor", handleClose);
|
||||
document.addEventListener("closeFindDialogFromEditor", handleClose);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("openSearchNavigationDialog", handleOpen);
|
||||
document.removeEventListener("openFindDialogFromEditor", handleClose);
|
||||
document.removeEventListener("closeFindDialogFromEditor", handleClose);
|
||||
};
|
||||
}, [close, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTransaction = () => {
|
||||
if (!openRef.current || editor.isDestroyed) return;
|
||||
|
||||
const { results } = editor.storage.searchAndReplace;
|
||||
if (results.length === 0) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
editor.on("transaction", handleTransaction);
|
||||
return () => {
|
||||
editor.off("transaction", handleTransaction);
|
||||
};
|
||||
}, [close, editor]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={classes.findDialog}
|
||||
opened={open}
|
||||
size="xs"
|
||||
radius="md"
|
||||
w="auto"
|
||||
position={{ top: 90, right: 50 }}
|
||||
withBorder
|
||||
aria-label="Search navigation"
|
||||
>
|
||||
<Flex align="center" gap="xs">
|
||||
<Text size="xs" style={{ flex: 1 }}>
|
||||
{resultState.resultsLength > 0
|
||||
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
|
||||
: t("Not found")}
|
||||
</Text>
|
||||
<Tooltip label="Previous match">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={previous}
|
||||
aria-label="Previous match"
|
||||
disabled={resultState.resultsLength === 0}
|
||||
>
|
||||
<IconArrowNarrowUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Next match">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={next}
|
||||
aria-label="Next match"
|
||||
disabled={resultState.resultsLength === 0}
|
||||
>
|
||||
<IconArrowNarrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Close">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchNavigationDialog;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { useEditor } from "@tiptap/react";
|
||||
|
||||
interface UseSearchNavigationParamsProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
isSynced: boolean;
|
||||
pageId: string;
|
||||
searchParams: URLSearchParams;
|
||||
showStatic: boolean;
|
||||
}
|
||||
|
||||
export function useSearchNavigationParams({
|
||||
editor,
|
||||
isSynced,
|
||||
pageId,
|
||||
searchParams,
|
||||
showStatic,
|
||||
}: UseSearchNavigationParamsProps) {
|
||||
const appliedSearchKeyRef = useRef<string | null>(null);
|
||||
const searchKey = `${pageId}:${searchParams.toString()}`;
|
||||
|
||||
useEffect(() => {
|
||||
const searchQueries = searchParams.getAll("q");
|
||||
if (!searchQueries.length) {
|
||||
appliedSearchKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!editor ||
|
||||
editor.isDestroyed ||
|
||||
!editor.view.dom.isConnected ||
|
||||
appliedSearchKeyRef.current === searchKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = searchParams.get("m");
|
||||
appliedSearchKeyRef.current = searchKey;
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("openSearchNavigationDialog", {
|
||||
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
|
||||
}),
|
||||
);
|
||||
}, [editor, isSynced, searchKey, searchParams, showStatic]);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Editor, Node } from "@tiptap/core";
|
||||
import { GapCursor } from "@tiptap/pm/gapcursor";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TiptapDocument } from "./document";
|
||||
|
||||
const Footnotes = Node.create({
|
||||
name: "footnotes",
|
||||
group: "",
|
||||
content: "paragraph*",
|
||||
isolating: true,
|
||||
renderHTML() {
|
||||
return ["ol", { class: "footnotes" }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
const AtomBlock = Node.create({
|
||||
name: "atomBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
renderHTML() {
|
||||
return ["div", { "data-atom-block": "" }];
|
||||
},
|
||||
});
|
||||
|
||||
const IsolatingBlock = Node.create({
|
||||
name: "isolatingBlock",
|
||||
group: "block",
|
||||
content: "paragraph+",
|
||||
isolating: true,
|
||||
renderHTML() {
|
||||
return ["div", { "data-isolating-block": "" }, 0];
|
||||
},
|
||||
});
|
||||
|
||||
function createEditor(content: object[]) {
|
||||
const element = document.createElement("div");
|
||||
document.body.appendChild(element);
|
||||
|
||||
return new Editor({
|
||||
element,
|
||||
extensions: [
|
||||
TiptapDocument,
|
||||
StarterKit.configure({ document: false }),
|
||||
Footnotes,
|
||||
AtomBlock,
|
||||
IsolatingBlock,
|
||||
],
|
||||
content: { type: "doc", content },
|
||||
});
|
||||
}
|
||||
|
||||
function pressKey(editor: Editor, key: string, keyCode: number) {
|
||||
editor.view.dom.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key,
|
||||
keyCode,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("TiptapDocument", () => {
|
||||
it("stops on the gap when arrowing down from a selected block node", () => {
|
||||
const editor = createEditor([
|
||||
{ type: "atomBlock" },
|
||||
{ type: "atomBlock" },
|
||||
{ type: "paragraph" },
|
||||
]);
|
||||
const gapPos = editor.state.doc.child(0).nodeSize;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(
|
||||
NodeSelection.create(editor.state.doc, 0),
|
||||
),
|
||||
);
|
||||
|
||||
pressKey(editor, "ArrowDown", 40);
|
||||
|
||||
expect(editor.state.selection).toBeInstanceOf(GapCursor);
|
||||
expect(editor.state.selection.head).toBe(gapPos);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("stops on the gap when arrowing right out of an isolating block", () => {
|
||||
const paragraph = (text: string) => ({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
const editor = createEditor([
|
||||
{ type: "isolatingBlock", content: [paragraph("a")] },
|
||||
{ type: "isolatingBlock", content: [paragraph("b")] },
|
||||
{ type: "paragraph" },
|
||||
]);
|
||||
const gapPos = editor.state.doc.child(0).nodeSize;
|
||||
const endOfFirstText = gapPos - 2;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(
|
||||
TextSelection.create(editor.state.doc, endOfFirstText),
|
||||
),
|
||||
);
|
||||
|
||||
pressKey(editor, "ArrowRight", 39);
|
||||
|
||||
expect(editor.state.selection).toBeInstanceOf(GapCursor);
|
||||
expect(editor.state.selection.head).toBe(gapPos);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
|
||||
// With `block+ footnotes?`, ProseMirror's defaultType after the first block is
|
||||
// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap.
|
||||
export const TiptapDocument = Document.extend({
|
||||
content: "block+ footnotes?",
|
||||
allowGapCursor: true,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { markInputRule } from "@tiptap/core";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { TiptapDocument } from "@/features/editor/extensions/document";
|
||||
import { Code } from "@tiptap/extension-code";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||
@@ -148,9 +148,7 @@ export const mainExtensions = [
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
}),
|
||||
Document.extend({
|
||||
content: "block+ footnotes?",
|
||||
}),
|
||||
TiptapDocument,
|
||||
// Override TipTap's Code extension to fix the inline code input rule.
|
||||
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||
// before the opening backtick as part of the match, causing markInputRule
|
||||
|
||||
@@ -65,11 +65,13 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx";
|
||||
import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { extractPageSlugId, platformModifierKey } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
@@ -199,6 +201,7 @@ function CollabPageEditor({
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const canScroll = useCallback(
|
||||
@@ -437,6 +440,14 @@ function CollabPageEditor({
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const [showStatic, setShowStatic] = useState(true);
|
||||
|
||||
useSearchNavigationParams({
|
||||
editor,
|
||||
isSynced,
|
||||
pageId,
|
||||
searchParams,
|
||||
showStatic,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
@@ -460,6 +471,7 @@ function CollabPageEditor({
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
{editor && <SearchNavigationDialog editor={editor} />}
|
||||
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
|
||||
@@ -25,11 +25,37 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
|
||||
return `${titleSlug}-${pageSlugId}`;
|
||||
};
|
||||
|
||||
function appendSearchParams(
|
||||
url: string,
|
||||
search?: string[],
|
||||
wholeWord?: boolean,
|
||||
): string {
|
||||
if(search?.length === 0){
|
||||
return url;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
search
|
||||
?.map((term) => term.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((term) => params.append("q", term));
|
||||
|
||||
if (wholeWord) {
|
||||
params.set("m", "whole");
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
return queryString ? `${url}?${queryString}` : url;
|
||||
}
|
||||
|
||||
export const buildPageUrl = (
|
||||
spaceName: string,
|
||||
pageSlugId: string,
|
||||
pageTitle?: string,
|
||||
anchorId?: string,
|
||||
search?: string[],
|
||||
wholeWord?: boolean,
|
||||
): string => {
|
||||
let url: string;
|
||||
if (spaceName === undefined) {
|
||||
@@ -37,6 +63,9 @@ export const buildPageUrl = (
|
||||
} else {
|
||||
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
|
||||
url = appendSearchParams(url, search, wholeWord);
|
||||
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
};
|
||||
|
||||
@@ -45,14 +74,19 @@ export const buildSharedPageUrl = (opts: {
|
||||
pageSlugId: string;
|
||||
pageTitle?: string;
|
||||
anchorId?: string;
|
||||
search?: string[];
|
||||
wholeWord?: boolean;
|
||||
}): string => {
|
||||
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
|
||||
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
|
||||
let url: string;
|
||||
if (!shareId) {
|
||||
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
|
||||
url = appendSearchParams(url, search, wholeWord);
|
||||
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { SearchMobileControl } from "@/features/search/components/search-control
|
||||
import styles from "./docs.module.css";
|
||||
|
||||
const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree);
|
||||
const MANTINE_COLOR_SCHEME_ATTRIBUTE = "data-mantine-color-scheme";
|
||||
const DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE = "data-docs-print-color-scheme";
|
||||
|
||||
type DocsShellProps = {
|
||||
surface: DocsSurface;
|
||||
@@ -47,6 +49,45 @@ export default function DocsShell({
|
||||
);
|
||||
const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
let previousColorScheme: string | null = null;
|
||||
let isPrinting = false;
|
||||
|
||||
const restoreColorScheme = () => {
|
||||
if (!isPrinting) return;
|
||||
|
||||
if (previousColorScheme === null) {
|
||||
root.removeAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
|
||||
} else {
|
||||
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, previousColorScheme);
|
||||
}
|
||||
root.removeAttribute(DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE);
|
||||
isPrinting = false;
|
||||
};
|
||||
|
||||
const useLightPrintTheme = () => {
|
||||
if (isPrinting) return;
|
||||
|
||||
previousColorScheme = root.getAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE);
|
||||
root.setAttribute(
|
||||
DOCS_PRINT_COLOR_SCHEME_ATTRIBUTE,
|
||||
previousColorScheme ?? "light",
|
||||
);
|
||||
root.setAttribute(MANTINE_COLOR_SCHEME_ATTRIBUTE, "light");
|
||||
isPrinting = true;
|
||||
};
|
||||
|
||||
window.addEventListener("beforeprint", useLightPrintTheme);
|
||||
window.addEventListener("afterprint", restoreColorScheme);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeprint", useLightPrintTheme);
|
||||
window.removeEventListener("afterprint", restoreColorScheme);
|
||||
restoreColorScheme();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DocsSurfaceProvider value={surface}>
|
||||
<div className={clsx(styles.root, "public-typography")}>
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
--docs-accent: #2b7af1;
|
||||
--docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
|
||||
|
||||
/* Cloudflare-style single-ink model: one foreground for headings, bold, and
|
||||
* body on a just-off-white page; neither end of the scale is pure. */
|
||||
--docs-bg: oklch(99% 0 0);
|
||||
--docs-fg: oklch(21% 0 0);
|
||||
--docs-content-fg: var(--docs-fg);
|
||||
@@ -410,7 +408,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Expanded parents read as section headers, Cloudflare-style. */
|
||||
/* Expanded parents read as section headers. */
|
||||
.treeRow[data-open-parent="true"] {
|
||||
color: var(--docs-fg);
|
||||
font-weight: 500;
|
||||
@@ -543,8 +541,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
|
||||
|
||||
/* ---------- Footer branding ---------- */
|
||||
|
||||
.footer {
|
||||
@@ -773,13 +769,11 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
|
||||
* the shared editor and .public-typography rules. */
|
||||
.root.root :global(.ProseMirror) h1 {
|
||||
font-size: 2.1875rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.root.root :global(.ProseMirror) h2 {
|
||||
@@ -900,3 +894,59 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- Print ---------- */
|
||||
|
||||
@page public-doc {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Only the article prints; the body grid collapses so it takes the page width. */
|
||||
@media print {
|
||||
:global(html):has(.root),
|
||||
:global(body):has(.root) {
|
||||
page: public-doc;
|
||||
background-color: #fff !important;
|
||||
}
|
||||
|
||||
:global(html[data-docs-print-color-scheme="dark"])
|
||||
.root.root
|
||||
:global(.codeBlock svg) {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
.header,
|
||||
.sidebar,
|
||||
.toc,
|
||||
.articleActions,
|
||||
.breadcrumbs,
|
||||
.pageNav,
|
||||
.footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.root {
|
||||
--docs-bg: #fff;
|
||||
--docs-fg: #1f1f1f;
|
||||
--docs-content-fg: var(--docs-fg);
|
||||
--docs-nav-fg: #495057;
|
||||
--docs-muted: #5f6368;
|
||||
--docs-hover: #f1f3f5;
|
||||
--docs-faint: #868e96;
|
||||
--docs-border: #dee2e6;
|
||||
--docs-header-bg: #fff;
|
||||
|
||||
min-height: 0;
|
||||
background-color: #fff;
|
||||
color: var(--docs-fg);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.article {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ export function SearchResultItem({
|
||||
pageResult.space.slug,
|
||||
pageResult.slugId,
|
||||
pageResult.title,
|
||||
undefined,
|
||||
pageResult.matchedText,
|
||||
pageResult.wholeWord
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface IPageSearch {
|
||||
updatedAt: Date;
|
||||
rank: string;
|
||||
highlight: string;
|
||||
matchedText: string[];
|
||||
wholeWord: boolean;
|
||||
space: Partial<ISpace>;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ export class FavoriteController {
|
||||
|
||||
await this.favoriteService.addFavorite(user.id, workspace.id, {
|
||||
type: dto.type,
|
||||
pageId: dto.pageId,
|
||||
pageId: dto.type === 'page' ? dto.pageId : undefined,
|
||||
spaceId: dto.type === 'space' ? resolved.spaceId : undefined,
|
||||
templateId: dto.templateId,
|
||||
templateId: dto.type === 'template' ? dto.templateId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import {
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { InsertableFavorite } from '@docmost/db/types/entity.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(
|
||||
private readonly favoriteRepo: FavoriteRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async getFavoriteIds(
|
||||
@@ -43,12 +41,6 @@ export class FavoriteService {
|
||||
result.items = result.items.filter((id) => accessibleSet.has(id));
|
||||
}
|
||||
|
||||
if (type === FavoriteType.SPACE) {
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
result.items = result.items.filter((id) => spaceSet.has(id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -111,9 +103,6 @@ export class FavoriteService {
|
||||
return result;
|
||||
}
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
const spaceSet = new Set(userSpaceIds);
|
||||
|
||||
const pageFavorites = result.items.filter(
|
||||
(f) => f.type === FavoriteType.PAGE && f.pageId,
|
||||
);
|
||||
@@ -129,19 +118,11 @@ export class FavoriteService {
|
||||
accessiblePageSet = new Set(accessibleIds);
|
||||
}
|
||||
|
||||
result.items = result.items.filter((f) => {
|
||||
if (f.type === FavoriteType.PAGE) {
|
||||
return f.pageId && accessiblePageSet?.has(f.pageId);
|
||||
}
|
||||
if (f.type === FavoriteType.SPACE) {
|
||||
return f.spaceId && spaceSet.has(f.spaceId);
|
||||
}
|
||||
if (f.type === FavoriteType.TEMPLATE) {
|
||||
const templateSpaceId = (f as any).template?.spaceId;
|
||||
return !templateSpaceId || spaceSet.has(templateSpaceId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
result.items = result.items.filter(
|
||||
(f) =>
|
||||
f.type !== FavoriteType.PAGE ||
|
||||
(f.pageId && accessiblePageSet?.has(f.pageId)),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -20,7 +21,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { dbOrTx, executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import {
|
||||
@@ -174,10 +175,14 @@ export class PageService {
|
||||
return page;
|
||||
}
|
||||
|
||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||
async nextPagePosition(
|
||||
spaceId: string,
|
||||
parentPageId?: string,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
let pagePosition: string;
|
||||
|
||||
const lastPageQuery = this.db
|
||||
const lastPageQuery = dbOrTx(this.db, trx)
|
||||
.selectFrom('pages')
|
||||
.select(['position'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
@@ -391,35 +396,46 @@ export class PageService {
|
||||
}
|
||||
|
||||
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
|
||||
let childPageIds: string[] = [];
|
||||
return executeTx(this.db, async (trx) => {
|
||||
await this.pageRepo.lockPageHierarchySpaces(
|
||||
[rootPage.spaceId, spaceId],
|
||||
trx,
|
||||
);
|
||||
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: false,
|
||||
});
|
||||
const currentRootPage = await this.pageRepo.findById(rootPage.id, {
|
||||
trx,
|
||||
});
|
||||
if (!currentRootPage || currentRootPage.deletedAt) {
|
||||
throw new NotFoundException('Page to move not found');
|
||||
}
|
||||
if (currentRootPage.spaceId !== rootPage.spaceId) {
|
||||
throw new ConflictException('Page location changed; retry the move');
|
||||
}
|
||||
|
||||
// Filter to only accessible pages while maintaining tree integrity
|
||||
const accessiblePages = await this.filterAccessibleTreePages(
|
||||
allPages,
|
||||
rootPage.id,
|
||||
userId,
|
||||
rootPage.spaceId,
|
||||
);
|
||||
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
||||
const allPages = await this.pageRepo.getPageAndDescendants(
|
||||
currentRootPage.id,
|
||||
{ includeContent: false, trx },
|
||||
);
|
||||
const accessiblePages = await this.filterAccessibleTreePages(
|
||||
allPages,
|
||||
currentRootPage.id,
|
||||
userId,
|
||||
currentRootPage.spaceId,
|
||||
);
|
||||
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
|
||||
const pagesToOrphan = allPages.filter(
|
||||
(p) =>
|
||||
!accessibleIds.has(p.id) &&
|
||||
p.parentPageId &&
|
||||
accessibleIds.has(p.parentPageId),
|
||||
);
|
||||
|
||||
// Find inaccessible pages whose parent is being moved - these need to be orphaned
|
||||
const pagesToOrphan = allPages.filter(
|
||||
(p) =>
|
||||
!accessibleIds.has(p.id) &&
|
||||
p.parentPageId &&
|
||||
accessibleIds.has(p.parentPageId),
|
||||
);
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
// Orphan inaccessible child pages (make them root pages in original space)
|
||||
for (const page of pagesToOrphan) {
|
||||
const orphanPosition = await this.nextPagePosition(
|
||||
rootPage.spaceId,
|
||||
currentRootPage.spaceId,
|
||||
null,
|
||||
trx,
|
||||
);
|
||||
await this.pageRepo.updatePage(
|
||||
{ parentPageId: null, position: orphanPosition },
|
||||
@@ -429,16 +445,18 @@ export class PageService {
|
||||
}
|
||||
|
||||
// Update root page
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
const nextPosition = await this.nextPagePosition(spaceId, null, trx);
|
||||
await this.pageRepo.updatePage(
|
||||
{ spaceId, parentPageId: null, position: nextPosition },
|
||||
rootPage.id,
|
||||
currentRootPage.id,
|
||||
trx,
|
||||
);
|
||||
|
||||
const pageIdsToMove = accessiblePages.map((p) => p.id);
|
||||
|
||||
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
|
||||
const childPageIds = pageIdsToMove.filter(
|
||||
(id) => id !== currentRootPage.id,
|
||||
);
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
@@ -501,7 +519,7 @@ export class PageService {
|
||||
{
|
||||
pageIds: pageIdsToMove,
|
||||
spaceId,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
workspaceId: currentRootPage.workspaceId,
|
||||
},
|
||||
{
|
||||
attempts: 2,
|
||||
@@ -512,9 +530,9 @@ export class PageService {
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { childPageIds };
|
||||
return { childPageIds };
|
||||
});
|
||||
}
|
||||
|
||||
async duplicatePage(
|
||||
@@ -825,31 +843,59 @@ export class PageService {
|
||||
throw new BadRequestException('A page cannot be its own parent');
|
||||
}
|
||||
|
||||
let parentPageId = null;
|
||||
if (movedPage.parentPageId === dto.parentPageId) {
|
||||
parentPageId = undefined;
|
||||
} else {
|
||||
// changing the page's parent
|
||||
if (dto.parentPageId) {
|
||||
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
||||
if (
|
||||
!parentPage ||
|
||||
parentPage.deletedAt ||
|
||||
parentPage.spaceId !== movedPage.spaceId
|
||||
) {
|
||||
throw new NotFoundException('Parent page not found');
|
||||
}
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
}
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.pageRepo.lockPageHierarchySpaces(
|
||||
[movedPage.spaceId],
|
||||
trx,
|
||||
);
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
},
|
||||
dto.pageId,
|
||||
);
|
||||
const currentPage = await this.pageRepo.findById(dto.pageId, { trx });
|
||||
if (!currentPage || currentPage.deletedAt) {
|
||||
throw new NotFoundException('Moved page not found');
|
||||
}
|
||||
if (currentPage.spaceId !== movedPage.spaceId) {
|
||||
throw new ConflictException('Page location changed; retry the move');
|
||||
}
|
||||
|
||||
let parentPageId = null;
|
||||
if (currentPage.parentPageId === dto.parentPageId) {
|
||||
parentPageId = undefined;
|
||||
} else {
|
||||
if (dto.parentPageId) {
|
||||
const parentPage = await this.pageRepo.findById(dto.parentPageId, {
|
||||
trx,
|
||||
});
|
||||
if (
|
||||
!parentPage ||
|
||||
parentPage.deletedAt ||
|
||||
parentPage.spaceId !== currentPage.spaceId
|
||||
) {
|
||||
throw new NotFoundException('Parent page not found');
|
||||
}
|
||||
if (
|
||||
await this.pageRepo.isPageDescendant(
|
||||
dto.pageId,
|
||||
parentPage.id,
|
||||
trx,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'A page cannot be moved under its descendant',
|
||||
);
|
||||
}
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
position: dto.position,
|
||||
parentPageId: parentPageId,
|
||||
},
|
||||
dto.pageId,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getPageBreadCrumbs(childPageId: string) {
|
||||
|
||||
@@ -8,6 +8,8 @@ export class SearchResponseDto {
|
||||
creatorId: string;
|
||||
rank: number;
|
||||
highlight: string;
|
||||
matchedText: string[];
|
||||
wholeWord: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
space: Partial<Space>;
|
||||
|
||||
@@ -191,11 +191,25 @@ export class SearchService {
|
||||
|
||||
//@ts-ignore
|
||||
const searchResults = results.map((result: SearchResponseDto) => {
|
||||
if (result.highlight) {
|
||||
result.highlight = result.highlight
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
result.wholeWord = true
|
||||
if (!result.highlight) {
|
||||
result.matchedText = [];
|
||||
return result;
|
||||
}
|
||||
|
||||
result.highlight = result.highlight
|
||||
.replace(/\r\n|\r|\n/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
result.matchedText = [
|
||||
...new Set(
|
||||
Array.from(
|
||||
result.highlight.matchAll(/<b>([^<]*)<\/b>/gi),
|
||||
(match) => match[1],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -60,16 +60,21 @@ export class ShareSeoController {
|
||||
|
||||
const pageId = this.extractPageSlugId(pageSlug);
|
||||
|
||||
const share = await this.shareService.getShareForPage(
|
||||
pageId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!share) {
|
||||
let title: string;
|
||||
let searchIndexing = false;
|
||||
try {
|
||||
const shared = await this.shareService.getSharedPage(
|
||||
{ pageId },
|
||||
workspace.id,
|
||||
{ includeContent: false },
|
||||
);
|
||||
title = shared.page.title;
|
||||
searchIndexing = shared.share.searchIndexing;
|
||||
} catch (err) {
|
||||
return this.sendIndex(indexFilePath, res);
|
||||
}
|
||||
|
||||
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
|
||||
const rawTitle = htmlEscape(title ?? 'untitled');
|
||||
const metaTitle =
|
||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
||||
|
||||
@@ -78,7 +83,7 @@ export class ShareSeoController {
|
||||
const metaTags = [
|
||||
`<meta property="og:title" content="${metaTitle}" />`,
|
||||
`<meta property="twitter:title" content="${metaTitle}" />`,
|
||||
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
||||
!searchIndexing ? `<meta name="robots" content="noindex" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
@@ -110,7 +110,11 @@ export class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
||||
async getSharedPage(
|
||||
dto: ShareInfoDto,
|
||||
workspaceId: string,
|
||||
opts?: { includeContent?: boolean },
|
||||
) {
|
||||
//TODO: we should resolve the page from the share id
|
||||
if (!dto.pageId) throw new NotFoundException('Shared page not found');
|
||||
|
||||
@@ -120,10 +124,13 @@ export class ShareService {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(dto.pageId, {
|
||||
includeContent: true,
|
||||
includeCreator: true,
|
||||
});
|
||||
const includeContent = opts?.includeContent !== false;
|
||||
const page = includeContent
|
||||
? await this.pageRepo.findById(dto.pageId, {
|
||||
includeContent: true,
|
||||
includeCreator: true,
|
||||
})
|
||||
: await this.pageRepo.findById(dto.pageId);
|
||||
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
@@ -137,7 +144,9 @@ export class ShareService {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
if (includeContent) {
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
}
|
||||
|
||||
return { page, share };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
|
||||
import { Space, User } from '@docmost/db/types/entity.types';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||
@@ -218,41 +218,18 @@ export class SpaceMemberService {
|
||||
dto: RemoveSpaceMemberDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
const memberTypeId = dto.userId
|
||||
? { userId: dto.userId }
|
||||
: dto.groupId
|
||||
? { groupId: dto.groupId }
|
||||
: null;
|
||||
|
||||
let spaceMember: SpaceMember = null;
|
||||
|
||||
if (dto.userId) {
|
||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
{
|
||||
userId: dto.userId,
|
||||
},
|
||||
);
|
||||
} else if (dto.groupId) {
|
||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
{
|
||||
groupId: dto.groupId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!memberTypeId) {
|
||||
throw new BadRequestException(
|
||||
'Please provide a valid userId or groupId to remove',
|
||||
);
|
||||
}
|
||||
|
||||
if (!spaceMember) {
|
||||
throw new NotFoundException('Space membership not found');
|
||||
}
|
||||
|
||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||
await this.validateLastAdmin(dto.spaceId);
|
||||
}
|
||||
|
||||
let affectedUserIds: string[] = [];
|
||||
if (dto.userId) {
|
||||
affectedUserIds = [dto.userId];
|
||||
@@ -262,7 +239,29 @@ export class SpaceMemberService {
|
||||
);
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
const { space, spaceMember } = await executeTx(this.db, async (trx) => {
|
||||
const space = await this.spaceRepo.findById(
|
||||
dto.spaceId,
|
||||
workspaceId,
|
||||
{ withLock: true, trx },
|
||||
);
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
memberTypeId,
|
||||
trx,
|
||||
);
|
||||
if (!spaceMember) {
|
||||
throw new NotFoundException('Space membership not found');
|
||||
}
|
||||
|
||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||
await this.validateLastAdmin(dto.spaceId, trx);
|
||||
}
|
||||
|
||||
await this.spaceMemberRepo.removeSpaceMemberById(
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
@@ -280,6 +279,8 @@ export class SpaceMemberService {
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
return { space, spaceMember };
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
@@ -304,48 +305,40 @@ export class SpaceMemberService {
|
||||
dto: UpdateSpaceMemberRoleDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
const memberTypeId = dto.userId
|
||||
? { userId: dto.userId }
|
||||
: dto.groupId
|
||||
? { groupId: dto.groupId }
|
||||
: null;
|
||||
|
||||
let spaceMember: SpaceMember = null;
|
||||
|
||||
if (dto.userId) {
|
||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
{
|
||||
userId: dto.userId,
|
||||
},
|
||||
);
|
||||
} else if (dto.groupId) {
|
||||
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
{
|
||||
groupId: dto.groupId,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!memberTypeId) {
|
||||
throw new BadRequestException(
|
||||
'Please provide a valid userId or groupId to remove',
|
||||
);
|
||||
}
|
||||
|
||||
if (!spaceMember) {
|
||||
throw new NotFoundException('Space membership not found');
|
||||
}
|
||||
const result = await executeTx(this.db, async (trx) => {
|
||||
const space = await this.spaceRepo.findById(
|
||||
dto.spaceId,
|
||||
workspaceId,
|
||||
{ withLock: true, trx },
|
||||
);
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
if (spaceMember.role === dto.role) {
|
||||
return;
|
||||
}
|
||||
const spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
|
||||
dto.spaceId,
|
||||
memberTypeId,
|
||||
trx,
|
||||
);
|
||||
if (!spaceMember) {
|
||||
throw new NotFoundException('Space membership not found');
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await trx
|
||||
.selectFrom('spaces')
|
||||
.select('id')
|
||||
.where('id', '=', dto.spaceId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (spaceMember.role === dto.role) {
|
||||
return { changed: false, space, spaceMember };
|
||||
}
|
||||
|
||||
if (spaceMember.role === SpaceRole.ADMIN) {
|
||||
await this.validateLastAdmin(dto.spaceId, trx);
|
||||
@@ -357,8 +350,16 @@ export class SpaceMemberService {
|
||||
dto.spaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
return { changed: true, space, spaceMember };
|
||||
});
|
||||
|
||||
if (!result.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { space, spaceMember } = result;
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResource.SPACE_MEMBER,
|
||||
@@ -387,7 +388,7 @@ export class SpaceMemberService {
|
||||
spaceId,
|
||||
trx,
|
||||
);
|
||||
if (spaceOwnerCount === 1) {
|
||||
if (spaceOwnerCount <= 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one space admin with full access',
|
||||
);
|
||||
|
||||
@@ -747,44 +747,61 @@ export class WorkspaceService {
|
||||
userRoleDto: UpdateWorkspaceUserRoleDto,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||
|
||||
const newRole = userRoleDto.role.toLowerCase();
|
||||
const result = await executeTx(this.db, async (trx) => {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLock: true,
|
||||
trx,
|
||||
});
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
// prevent ADMIN from managing OWNER role
|
||||
if (
|
||||
isAdminActingOnOwner(authUser.role, newRole) ||
|
||||
isAdminActingOnOwner(authUser.role, user.role)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
if (user.role === newRole) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
const user = await this.userRepo.findById(
|
||||
userRoleDto.userId,
|
||||
workspaceId,
|
||||
{ trx },
|
||||
);
|
||||
if (!user) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (
|
||||
isAdminActingOnOwner(authUser.role, newRole) ||
|
||||
isAdminActingOnOwner(authUser.role, user.role)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
if (user.role === newRole) {
|
||||
return { changed: false, user };
|
||||
}
|
||||
|
||||
if (
|
||||
user.role === UserRole.OWNER &&
|
||||
!user.deletedAt &&
|
||||
!user.deactivatedAt
|
||||
) {
|
||||
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
role: newRole,
|
||||
},
|
||||
user.id,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
return { changed: true, user };
|
||||
});
|
||||
|
||||
if (!result.changed) {
|
||||
return result.user;
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
role: newRole,
|
||||
},
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
const { user } = result;
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_ROLE_CHANGED,
|
||||
@@ -848,40 +865,38 @@ export class WorkspaceService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
const user = await executeTx(this.db, async (trx) => {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLock: true,
|
||||
trx,
|
||||
});
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
const user = await this.userRepo.findById(userId, workspaceId, { trx });
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (user.deactivatedAt) {
|
||||
throw new BadRequestException('User is already deactivated');
|
||||
}
|
||||
if (user.deactivatedAt) {
|
||||
throw new BadRequestException('User is already deactivated');
|
||||
}
|
||||
|
||||
if (authUser.id === userId) {
|
||||
throw new BadRequestException('You cannot deactivate yourself');
|
||||
}
|
||||
if (authUser.id === userId) {
|
||||
throw new BadRequestException('You cannot deactivate yourself');
|
||||
}
|
||||
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException(
|
||||
'You cannot deactivate a user with owner role',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.role === UserRole.OWNER) {
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (workspaceOwnerCount === 1) {
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
'You cannot deactivate a user with owner role',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (user.role === UserRole.OWNER) {
|
||||
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
@@ -889,6 +904,8 @@ export class WorkspaceService {
|
||||
trx,
|
||||
);
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
@@ -951,32 +968,34 @@ export class WorkspaceService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId);
|
||||
const user = await executeTx(this.db, async (trx) => {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLock: true,
|
||||
trx,
|
||||
});
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
const user = await this.userRepo.findById(userId, workspaceId, { trx });
|
||||
if (!user || user.deletedAt) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
if (authUser.id === userId) {
|
||||
throw new BadRequestException('You cannot delete yourself');
|
||||
}
|
||||
|
||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException(
|
||||
'You cannot delete a user with owner role',
|
||||
);
|
||||
}
|
||||
|
||||
if (authUser.id === userId) {
|
||||
throw new BadRequestException('You cannot delete yourself');
|
||||
}
|
||||
if (user.role === UserRole.OWNER && !user.deactivatedAt) {
|
||||
await this.validateLastWorkspaceOwner(workspaceId, trx);
|
||||
}
|
||||
|
||||
if (isAdminActingOnOwner(authUser.role, user.role)) {
|
||||
throw new BadRequestException('You cannot delete a user with owner role');
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
name: 'Deleted user',
|
||||
@@ -1009,6 +1028,8 @@ export class WorkspaceService {
|
||||
});
|
||||
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
@@ -1030,4 +1051,20 @@ export class WorkspaceService {
|
||||
// empty
|
||||
}
|
||||
}
|
||||
|
||||
private async validateLastWorkspaceOwner(
|
||||
workspaceId: string,
|
||||
trx: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
if (workspaceOwnerCount <= 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
export const FavoriteType = {
|
||||
PAGE: 'page',
|
||||
@@ -19,7 +20,10 @@ export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async insert(favorite: InsertableFavorite): Promise<Favorite | undefined> {
|
||||
try {
|
||||
@@ -82,6 +86,8 @@ export class FavoriteRepo {
|
||||
.where('favorites.workspaceId', '=', workspaceId)
|
||||
.where('favorites.type', '=', type);
|
||||
|
||||
query = this.applyMembershipFilter(query, userId);
|
||||
|
||||
if (spaceId) {
|
||||
query = this.applySpaceFilter(query, type, spaceId);
|
||||
}
|
||||
@@ -113,6 +119,8 @@ export class FavoriteRepo {
|
||||
.where('favorites.userId', '=', userId)
|
||||
.where('favorites.workspaceId', '=', workspaceId);
|
||||
|
||||
query = this.applyMembershipFilter(query, userId);
|
||||
|
||||
if (type) {
|
||||
query = query.where('favorites.type', '=', type);
|
||||
}
|
||||
@@ -155,7 +163,7 @@ export class FavoriteRepo {
|
||||
): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const { trx } = opts;
|
||||
const { trx } = opts ?? {};
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
const usersWithAccess = db
|
||||
@@ -174,7 +182,25 @@ export class FavoriteRepo {
|
||||
await db
|
||||
.deleteFrom('favorites')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', '=', spaceId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('pages.id', '=', 'favorites.pageId')
|
||||
.where('pages.spaceId', '=', spaceId),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('templates')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('templates.id', '=', 'favorites.templateId')
|
||||
.where('templates.spaceId', '=', spaceId),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.where('userId', 'not in', usersWithAccess)
|
||||
.execute();
|
||||
}
|
||||
@@ -194,6 +220,46 @@ export class FavoriteRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
private applyMembershipFilter<Q extends SelectQueryBuilder<any, any, any>>(
|
||||
query: Q,
|
||||
userId: string,
|
||||
): Q {
|
||||
const spaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
|
||||
return query.where((eb: any) =>
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb('favorites.type', '=', FavoriteType.SPACE),
|
||||
eb('favorites.spaceId', 'in', spaceIds),
|
||||
]),
|
||||
eb.and([
|
||||
eb('favorites.type', '=', FavoriteType.PAGE),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('pages.id', '=', 'favorites.pageId')
|
||||
.where('pages.spaceId', 'in', spaceIds),
|
||||
),
|
||||
]),
|
||||
eb.and([
|
||||
eb('favorites.type', '=', FavoriteType.TEMPLATE),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('templates')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('templates.id', '=', 'favorites.templateId')
|
||||
.where((e: any) =>
|
||||
e.or([
|
||||
e('templates.spaceId', 'is', null),
|
||||
e('templates.spaceId', 'in', spaceIds),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
) as Q;
|
||||
}
|
||||
|
||||
private applySpaceFilter<Q extends SelectQueryBuilder<any, any, any>>(
|
||||
query: Q,
|
||||
type: FavoriteType | undefined,
|
||||
@@ -239,7 +305,8 @@ export class FavoriteRepo {
|
||||
'pages.isBase',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
.whereRef('pages.id', '=', 'favorites.pageId')
|
||||
.where(sql.ref('favorites.type'), '=', FavoriteType.PAGE),
|
||||
).as('page');
|
||||
}
|
||||
|
||||
@@ -269,8 +336,8 @@ export class FavoriteRepo {
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.where(({ or, ref }) =>
|
||||
or([
|
||||
sql<boolean>`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
|
||||
sql<boolean>`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
|
||||
sql<boolean>`${ref('favorites.type')} = ${FavoriteType.SPACE} and ${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
|
||||
sql<boolean>`${ref('favorites.type')} = ${FavoriteType.PAGE} and ${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
|
||||
]),
|
||||
),
|
||||
).as('space');
|
||||
@@ -287,7 +354,8 @@ export class FavoriteRepo {
|
||||
'templates.icon',
|
||||
'templates.spaceId',
|
||||
])
|
||||
.whereRef('templates.id', '=', 'favorites.templateId'),
|
||||
.whereRef('templates.id', '=', 'favorites.templateId')
|
||||
.where(sql.ref('favorites.type'), '=', FavoriteType.TEMPLATE),
|
||||
).as('template');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,22 @@ export class PageRepo {
|
||||
return result;
|
||||
}
|
||||
|
||||
async lockPageHierarchySpaces(
|
||||
spaceIds: string[],
|
||||
trx: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const sortedSpaceIds = [...new Set(spaceIds)].sort();
|
||||
|
||||
for (const spaceId of sortedSpaceIds) {
|
||||
await sql`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtext('page-hierarchy'),
|
||||
hashtext(${spaceId})
|
||||
)
|
||||
`.execute(trx);
|
||||
}
|
||||
}
|
||||
|
||||
async insertPage(
|
||||
insertablePage: InsertablePage,
|
||||
trx?: KyselyTransaction,
|
||||
@@ -489,9 +505,9 @@ export class PageRepo {
|
||||
|
||||
async getPageAndDescendants(
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
opts: { includeContent: boolean; trx?: KyselyTransaction },
|
||||
) {
|
||||
return this.db
|
||||
return dbOrTx(this.db, opts.trx)
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -535,6 +551,36 @@ export class PageRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async isPageDescendant(
|
||||
ancestorPageId: string,
|
||||
descendantPageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<boolean> {
|
||||
const result = await dbOrTx(this.db, trx)
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'parentPageId'])
|
||||
.where('id', '=', descendantPageId)
|
||||
.union((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as parent')
|
||||
.select(['parent.id', 'parent.parentPageId'])
|
||||
.innerJoin(
|
||||
'page_ancestors as ancestor',
|
||||
'ancestor.parentPageId',
|
||||
'parent.id',
|
||||
),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select('id')
|
||||
.where('id', '=', ancestorPageId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page and all descendants, excluding restricted pages and their subtrees.
|
||||
* More efficient than getPageAndDescendants + filtering because:
|
||||
|
||||
@@ -25,7 +25,11 @@ export class SpaceRepo {
|
||||
async findById(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Space> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
@@ -41,6 +45,11 @@ export class SpaceRepo {
|
||||
} else {
|
||||
query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
|
||||
}
|
||||
|
||||
if (opts?.withLock && opts?.trx) {
|
||||
query = query.forUpdate();
|
||||
}
|
||||
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
|
||||
@@ -145,12 +145,16 @@ export class UserRepo {
|
||||
async roleCountByWorkspaceId(
|
||||
role: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const { count } = await this.db
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const { count } = await db
|
||||
.selectFrom('users')
|
||||
.select((eb) => eb.fn.count('role').as('count'))
|
||||
.where('role', '=', role)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return count as number;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 039bd87f8a...5e7120dcc8
+29
-29
@@ -31,34 +31,34 @@
|
||||
"@joplin/turndown": "4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||
"@sindresorhus/slugify": "3.0.0",
|
||||
"@tiptap/core": "3.29.2",
|
||||
"@tiptap/extension-audio": "3.29.2",
|
||||
"@tiptap/extension-code-block": "3.29.2",
|
||||
"@tiptap/extension-collaboration": "3.29.2",
|
||||
"@tiptap/extension-collaboration-caret": "3.29.2",
|
||||
"@tiptap/extension-color": "3.29.2",
|
||||
"@tiptap/extension-document": "3.29.2",
|
||||
"@tiptap/extension-heading": "3.29.2",
|
||||
"@tiptap/extension-highlight": "3.29.2",
|
||||
"@tiptap/extension-history": "3.29.2",
|
||||
"@tiptap/extension-image": "3.29.2",
|
||||
"@tiptap/extension-link": "3.29.2",
|
||||
"@tiptap/extension-list": "3.29.2",
|
||||
"@tiptap/extension-placeholder": "3.29.2",
|
||||
"@tiptap/extension-subscript": "3.29.2",
|
||||
"@tiptap/extension-superscript": "3.29.2",
|
||||
"@tiptap/extension-table": "3.29.2",
|
||||
"@tiptap/extension-text": "3.29.2",
|
||||
"@tiptap/extension-text-align": "3.29.2",
|
||||
"@tiptap/extension-text-style": "3.29.2",
|
||||
"@tiptap/extension-typography": "3.29.2",
|
||||
"@tiptap/extension-unique-id": "3.29.2",
|
||||
"@tiptap/extension-youtube": "3.29.2",
|
||||
"@tiptap/html": "3.29.2",
|
||||
"@tiptap/pm": "3.29.2",
|
||||
"@tiptap/react": "3.29.2",
|
||||
"@tiptap/starter-kit": "3.29.2",
|
||||
"@tiptap/suggestion": "3.29.2",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-audio": "3.31.3",
|
||||
"@tiptap/extension-code-block": "3.31.3",
|
||||
"@tiptap/extension-collaboration": "3.31.3",
|
||||
"@tiptap/extension-collaboration-caret": "3.31.3",
|
||||
"@tiptap/extension-color": "3.31.3",
|
||||
"@tiptap/extension-document": "3.31.3",
|
||||
"@tiptap/extension-heading": "3.31.3",
|
||||
"@tiptap/extension-highlight": "3.31.3",
|
||||
"@tiptap/extension-history": "3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-list": "3.31.3",
|
||||
"@tiptap/extension-placeholder": "3.31.3",
|
||||
"@tiptap/extension-subscript": "3.31.3",
|
||||
"@tiptap/extension-superscript": "3.31.3",
|
||||
"@tiptap/extension-table": "3.31.3",
|
||||
"@tiptap/extension-text": "3.31.3",
|
||||
"@tiptap/extension-text-align": "3.31.3",
|
||||
"@tiptap/extension-text-style": "3.31.3",
|
||||
"@tiptap/extension-typography": "3.31.3",
|
||||
"@tiptap/extension-unique-id": "3.31.3",
|
||||
"@tiptap/extension-youtube": "3.31.3",
|
||||
"@tiptap/html": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"@tiptap/suggestion": "3.31.3",
|
||||
"@tiptap/y-tiptap": "3.0.7",
|
||||
"bytes": "3.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -95,5 +95,5 @@
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@11.23.0"
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@ declare module "@tiptap/core" {
|
||||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
setSearchTerms: (searchTerms: string[]) => ReturnType;
|
||||
/**
|
||||
* @description Set whole word search in extension.
|
||||
*/
|
||||
setWholeWord: (wholeWord: boolean) => ReturnType;
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
@@ -82,13 +86,26 @@ interface TextNodesWithPosition {
|
||||
}
|
||||
|
||||
const getRegex = (
|
||||
s: string,
|
||||
searchTerms: string[],
|
||||
disableRegex: boolean,
|
||||
caseSensitive: boolean,
|
||||
wholeWord: boolean,
|
||||
): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
|
||||
caseSensitive ? "gu" : "gui",
|
||||
const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
|
||||
|
||||
const pattern = terms
|
||||
.map((term) =>
|
||||
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
|
||||
)
|
||||
.join("|");
|
||||
|
||||
const finalPattern = wholeWord
|
||||
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
|
||||
: pattern;
|
||||
|
||||
return new RegExp(
|
||||
finalPattern,
|
||||
caseSensitive ? "gu" : "giu",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -255,12 +272,14 @@ export interface SearchAndReplaceOptions {
|
||||
}
|
||||
|
||||
export interface SearchAndReplaceStorage {
|
||||
searchTerm: string;
|
||||
searchTerms: string[];
|
||||
replaceTerm: string;
|
||||
results: Range[];
|
||||
lastSearchTerm: string;
|
||||
lastSearchTerms: string[];
|
||||
caseSensitive: boolean;
|
||||
lastCaseSensitive: boolean;
|
||||
wholeWord: boolean;
|
||||
lastWholeWord: boolean;
|
||||
resultIndex: number;
|
||||
lastResultIndex: number;
|
||||
}
|
||||
@@ -280,11 +299,13 @@ export const SearchAndReplace = Extension.create<
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
searchTerms: [],
|
||||
replaceTerm: "",
|
||||
results: [],
|
||||
lastSearchTerm: "",
|
||||
lastSearchTerms: [],
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
lastWholeWord: false,
|
||||
lastCaseSensitive: false,
|
||||
resultIndex: 0,
|
||||
lastResultIndex: 0,
|
||||
@@ -293,10 +314,21 @@ export const SearchAndReplace = Extension.create<
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
setSearchTerms:
|
||||
(searchTerms: string[]) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||
editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean);
|
||||
|
||||
// clear whole word by default
|
||||
// should remove if whole word toggle is added to search and replace dialog
|
||||
editor.storage.searchAndReplace.wholeWord = false;
|
||||
|
||||
return false;
|
||||
},
|
||||
setWholeWord:
|
||||
(wholeWord: boolean) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.wholeWord = wholeWord;
|
||||
|
||||
return false;
|
||||
},
|
||||
@@ -409,8 +441,10 @@ export const SearchAndReplace = Extension.create<
|
||||
const editor = this.editor;
|
||||
const { searchResultClass, disableRegex } = this.options;
|
||||
|
||||
const setLastSearchTerm = (t: string) =>
|
||||
(editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||
const setLastSearchTerms = (terms: string[]) =>
|
||||
(editor.storage.searchAndReplace.lastSearchTerms = [...terms]);
|
||||
const setLastWholeWord = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastWholeWord = t);
|
||||
const setLastCaseSensitive = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||
const setLastResultIndex = (t: number) =>
|
||||
@@ -425,37 +459,47 @@ export const SearchAndReplace = Extension.create<
|
||||
const storage = editor.storage.searchAndReplace;
|
||||
if (!storage) return oldState;
|
||||
const {
|
||||
searchTerm,
|
||||
lastSearchTerm,
|
||||
caseSensitive,
|
||||
searchTerms,
|
||||
lastCaseSensitive,
|
||||
lastSearchTerms,
|
||||
caseSensitive,
|
||||
wholeWord,
|
||||
lastWholeWord,
|
||||
resultIndex,
|
||||
lastResultIndex,
|
||||
} = storage;
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
lastSearchTerm === searchTerm &&
|
||||
searchTerms.length === lastSearchTerms.length &&
|
||||
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
|
||||
lastCaseSensitive === caseSensitive &&
|
||||
lastWholeWord === wholeWord &&
|
||||
lastResultIndex === resultIndex
|
||||
)
|
||||
return oldState;
|
||||
|
||||
setLastSearchTerm(searchTerm);
|
||||
setLastSearchTerms(searchTerms);
|
||||
setLastCaseSensitive(caseSensitive);
|
||||
setLastWholeWord(wholeWord);
|
||||
setLastResultIndex(resultIndex);
|
||||
|
||||
if (!searchTerm) {
|
||||
if (searchTerms.length === 0) {
|
||||
editor.storage.searchAndReplace.results = [];
|
||||
return DecorationSet.empty;
|
||||
}
|
||||
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
doc,
|
||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass,
|
||||
resultIndex,
|
||||
);
|
||||
doc,
|
||||
getRegex(
|
||||
searchTerms,
|
||||
disableRegex,
|
||||
caseSensitive,
|
||||
wholeWord,
|
||||
),
|
||||
searchResultClass,
|
||||
resultIndex,
|
||||
);
|
||||
|
||||
editor.storage.searchAndReplace.results = results;
|
||||
|
||||
|
||||
Generated
+455
-445
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -16,11 +16,12 @@ overrides:
|
||||
express-rate-limit: 8.2.2
|
||||
flatted: 3.4.2
|
||||
find-my-way: 9.7.0
|
||||
fastify: 5.12.3
|
||||
yaml@>=2.0.0 <2.8.3: 2.8.3
|
||||
brace-expansion@^5: 5.0.9
|
||||
axios: 1.18.1
|
||||
ip-address: 10.3.1
|
||||
fast-uri: 3.1.5
|
||||
fast-uri: 3.1.7
|
||||
form-data@>=4.0.0 <4.0.6: 4.0.6
|
||||
nanoid@>=4.0.0 <5.1.16: 5.1.16
|
||||
esbuild@>=0.27.3 <0.28.1: 0.28.1
|
||||
@@ -29,6 +30,10 @@ overrides:
|
||||
js-yaml@>=4.0.0 <4.3.1: 4.3.1
|
||||
shamefullyHoist: true
|
||||
minimumReleaseAge: 4320
|
||||
minimumReleaseAgeExclude:
|
||||
- '@tiptap/*'
|
||||
- fastify
|
||||
- postcss-selector-parser
|
||||
allowBuilds:
|
||||
'@swc/core': true
|
||||
bcrypt: true
|
||||
|
||||
Reference in New Issue
Block a user