Files
dataroom/components/view/document-view.tsx
50a86ab311 Notion text selection (#2062)
* feat: add option to allow text selection on Notion pages

Add enableTextSelection field to Link model that allows document owners
to toggle text selection/copying for visitors on Notion pages.

Changes:
- Add enableTextSelection Boolean field to Link Prisma schema
- Add database migration for the new field
- Create TextSelectionSection toggle component for link settings
- Wire the setting through link sheet, link options, API routes
- Pass textSelectionEnabled prop to NotionPage viewer component
- Add .notion-text-selection-enabled CSS class that overrides
  user-select: none when text selection is allowed
- Update link-active-controls to show when text selection is active
- Update webhooks and link data queries to include new field

By default, text selection remains disabled (preserving existing
behavior). When enabled, visitors can select and copy text content
on Notion document pages.

Co-authored-by: Marc Seitz <[email protected]>

* refactor: use team feature flag for text selection instead of per-link toggle

Replace the per-link enableTextSelection database field with a team-level
feature flag (textSelection) using the existing Vercel Edge Config system.

This means text selection on Notion pages is controlled at the team level
via the betaFeatures edge config, so there's no per-link toggle to manage.

Changes:
- Remove enableTextSelection from Link Prisma schema and migration
- Remove TextSelectionSection toggle component and link sheet wiring
- Add 'textSelection' to BetaFeatures type in featureFlags
- Fetch textSelection flag in all view page getStaticProps:
  - pages/view/[linkId]/index.tsx (document + dataroom paths)
  - pages/view/domains/[domain]/[slug]/index.tsx (document + dataroom)
  - pages/view/[linkId]/d/[documentId].tsx (dataroom document)
  - pages/view/domains/[domain]/[slug]/d/[documentId].tsx (domain dataroom doc)
- Pass textSelectionEnabled prop through component chain:
  DocumentView -> ViewData -> NotionPage
  DataroomDocumentView -> ViewData -> NotionPage
- Keep CSS override (.notion-text-selection-enabled) and NotionPage
  conditional class application from previous commit

To enable: add the team's ID to the 'textSelection' array in
Vercel Edge Config betaFeatures.

Co-authored-by: Marc Seitz <[email protected]>

---------

Co-authored-by: Marc Seitz <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-19 14:37:27 +11:00

329 lines
9.0 KiB
TypeScript

import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react";
import { Brand } from "@prisma/client";
import Cookies from "js-cookie";
import { ExtendedRecordMap } from "notion-types";
import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
import { LinkWithDocument, NotionTheme } from "@/lib/types";
import LoadingSpinner from "@/components/ui/loading-spinner";
import AccessForm, {
DEFAULT_ACCESS_FORM_DATA,
DEFAULT_ACCESS_FORM_TYPE,
} from "@/components/view/access-form";
import EmailVerificationMessage from "./access-form/email-verification-form";
import ViewData, { TViewDocumentData } from "./view-data";
type RowData = { [key: string]: any };
type SheetData = {
sheetName: string;
columnData: string[];
rowData: RowData[];
};
export type DEFAULT_DOCUMENT_VIEW_TYPE = {
viewId?: string;
file?: string | null;
pages?:
| {
file: string;
pageNumber: string;
embeddedLinks: string[];
pageLinks: {
href: string;
coords: string;
isInternal?: boolean;
targetPage?: number;
}[];
metadata: { width: number; height: number; scaleFactor: number };
}[]
| null;
sheetData?: SheetData[] | null;
fileType?: string;
isPreview?: boolean;
ipAddress?: string;
verificationToken?: string;
isTeamMember?: boolean;
agentsEnabled?: boolean;
viewerId?: string;
};
export default function DocumentView({
link,
userEmail,
userId,
isProtected,
notionData,
brand,
token,
verifiedEmail,
showPoweredByBanner,
showAccountCreationSlide,
useAdvancedExcelViewer,
previewToken,
disableEditEmail,
useCustomAccessForm,
logoOnAccessForm,
isEmbedded,
annotationsEnabled,
textSelectionEnabled,
}: {
link: LinkWithDocument;
userEmail: string | null | undefined;
userId: string | null | undefined;
isProtected: boolean;
notionData?: {
rootNotionPageId: string | null;
recordMap: ExtendedRecordMap | null;
theme: NotionTheme | null;
};
brand?: Partial<Brand> | null;
token?: string;
verifiedEmail?: string;
showPoweredByBanner?: boolean;
showAccountCreationSlide?: boolean;
useAdvancedExcelViewer?: boolean;
previewToken?: string;
disableEditEmail?: boolean;
useCustomAccessForm?: boolean;
isEmbedded?: boolean;
logoOnAccessForm?: boolean;
annotationsEnabled?: boolean;
textSelectionEnabled?: boolean;
}) {
useDisablePrint();
const {
document,
emailProtected,
password: linkPassword,
enableAgreement,
} = link;
const analytics = useAnalytics();
const router = useRouter();
const didMount = useRef<boolean>(false);
const [submitted, setSubmitted] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [viewData, setViewData] = useState<DEFAULT_DOCUMENT_VIEW_TYPE>({
viewId: "",
});
const [data, setData] = useState<DEFAULT_ACCESS_FORM_TYPE>(
DEFAULT_ACCESS_FORM_DATA,
);
const [verificationRequested, setVerificationRequested] =
useState<boolean>(false);
const [verificationToken, setVerificationToken] = useState<string | null>(
token ?? null,
);
const [code, setCode] = useState<string | null>(null);
const [isInvalidCode, setIsInvalidCode] = useState<boolean>(false);
const handleSubmission = async (): Promise<void> => {
setIsLoading(true);
const response = await fetch("/api/views", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
email: data.email ?? verifiedEmail ?? userEmail ?? null,
linkId: link.id,
documentId: document.id,
documentName: document.name,
ownerId: document.ownerId,
userId: userId ?? null,
documentVersionId: document.versions[0].id,
hasPages: document.versions[0].hasPages,
useAdvancedExcelViewer,
previewToken,
code: code ?? undefined,
token: verificationToken ?? undefined,
verifiedEmail: verifiedEmail ?? undefined,
}),
});
if (response.ok) {
const fetchData = await response.json();
if (fetchData.type === "email-verification") {
setVerificationRequested(true);
setIsLoading(false);
} else {
const {
viewId,
file,
pages,
sheetData,
fileType,
isPreview,
ipAddress,
verificationToken,
agentsEnabled,
isTeamMember,
viewerId,
} = fetchData as DEFAULT_DOCUMENT_VIEW_TYPE;
analytics.identify(
userEmail ?? verifiedEmail ?? data.email ?? undefined,
);
analytics.capture("Link Viewed", {
linkId: link.id,
documentId: document.id,
linkType: "DOCUMENT_LINK",
viewerId: viewId,
viewerEmail: data.email ?? verifiedEmail ?? userEmail,
isEmbedded,
isTeamMember,
teamId: link.teamId,
});
// set the verification token to the cookie
if (verificationToken) {
Cookies.set("pm_vft", verificationToken, {
path: router.asPath.split("?")[0],
expires: 1,
sameSite: "strict",
secure: true,
});
setCode(null);
}
setViewData({
viewId,
file,
pages,
sheetData,
fileType,
isPreview,
ipAddress,
isTeamMember,
agentsEnabled,
viewerId,
});
setSubmitted(true);
setVerificationRequested(false);
setIsLoading(false);
}
} else {
const data = await response.json();
toast.error(data.message);
if (data.resetVerification) {
const currentPath = router.asPath.split("?")[0];
Cookies.remove("pm_vft", { path: currentPath });
setVerificationToken(null);
setCode(null);
setIsInvalidCode(true);
}
setIsLoading(false);
}
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
event: React.FormEvent,
): Promise<void> => {
event.preventDefault();
await handleSubmission();
};
// If token is present, run handle submit which will verify token and get document
// If link is not submitted and does not have email / password protection, show the access form
useEffect(() => {
if (!didMount.current) {
if ((!submitted && !isProtected) || token || previewToken) {
handleSubmission();
}
didMount.current = true;
}
}, [submitted, isProtected, token, previewToken]);
// Components to render when email is submitted but verification is pending
if (verificationRequested) {
return (
<EmailVerificationMessage
onSubmitHandler={handleSubmit}
data={data}
isLoading={isLoading}
code={code}
setCode={setCode}
isInvalidCode={isInvalidCode}
setIsInvalidCode={setIsInvalidCode}
brand={brand}
/>
);
}
// If link is not submitted and does not have email / password protection, show the access form
if (!submitted && isProtected) {
return (
<AccessForm
data={data}
email={userEmail}
setData={setData}
onSubmitHandler={handleSubmit}
requireEmail={emailProtected}
requirePassword={!!linkPassword}
requireAgreement={enableAgreement!}
agreementName={link.agreement?.name}
agreementContent={link.agreement?.content}
agreementContentType={link.agreement?.contentType}
requireName={link.agreement?.requireName}
isLoading={isLoading}
brand={brand}
disableEditEmail={disableEditEmail}
useCustomAccessForm={useCustomAccessForm}
customFields={link.customFields}
logoOnAccessForm={logoOnAccessForm}
linkWelcomeMessage={link.welcomeMessage}
/>
);
}
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<LoadingSpinner className="h-20 w-20" />
</div>
);
}
return (
<div
className="bg-gray-950"
style={{
backgroundColor:
brand && brand.accentColor ? brand.accentColor : "rgb(3, 7, 18)",
}}
>
{submitted ? (
<ViewData
link={link}
viewData={viewData}
document={document as unknown as TViewDocumentData}
notionData={notionData}
brand={brand}
showPoweredByBanner={showPoweredByBanner}
showAccountCreationSlide={showAccountCreationSlide}
useAdvancedExcelViewer={useAdvancedExcelViewer}
viewerEmail={data.email ?? verifiedEmail ?? userEmail ?? undefined}
annotationsEnabled={annotationsEnabled}
textSelectionEnabled={textSelectionEnabled}
/>
) : (
<div className="flex h-screen items-center justify-center">
<LoadingSpinner className="h-20 w-20" />
</div>
)}
</div>
);
}