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 <mfts@users.noreply.github.com>

* 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 <mfts@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
This commit is contained in:
Marc Seitz
2026-02-19 14:37:27 +11:00
committed by GitHub
co-authored by Cursor Agent Marc Seitz
parent 10d58b0832
commit 5401d3d5d2
12 changed files with 88 additions and 4 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ export const DEFAULT_LINK_PROPS = (
enableNotification: true,
enableFeedback: false,
enableScreenshotProtection: false,
enableCustomMetatag: false,
enableCustomMetatag: false,
metaTitle: null,
metaDescription: null,
metaImage: null,
@@ -84,6 +84,7 @@ export default function DataroomDocumentView({
isEmbedded,
preview,
logoOnAccessForm,
textSelectionEnabled,
}: {
link: LinkWithDataroomDocument;
userEmail: string | null | undefined;
@@ -104,6 +105,7 @@ export default function DataroomDocumentView({
isEmbedded?: boolean;
preview?: boolean;
logoOnAccessForm?: boolean;
textSelectionEnabled?: boolean;
}) {
useDisablePrint();
const {
@@ -362,6 +364,7 @@ export default function DataroomDocumentView({
undefined
}
canDownload={viewData.canDownload}
textSelectionEnabled={textSelectionEnabled}
/>
) : (
<div className="flex h-screen items-center justify-center">
@@ -62,6 +62,7 @@ export default function DataroomView({
isEmbedded,
preview,
dataroomIndexEnabled,
textSelectionEnabled,
}: {
link: LinkWithDataroom;
userEmail: string | null | undefined;
@@ -77,6 +78,7 @@ export default function DataroomView({
preview?: boolean;
logoOnAccessForm?: boolean;
dataroomIndexEnabled?: boolean;
textSelectionEnabled?: boolean;
}) {
useDisablePrint();
const {
+3
View File
@@ -72,6 +72,7 @@ export default function DocumentView({
logoOnAccessForm,
isEmbedded,
annotationsEnabled,
textSelectionEnabled,
}: {
link: LinkWithDocument;
userEmail: string | null | undefined;
@@ -94,6 +95,7 @@ export default function DocumentView({
isEmbedded?: boolean;
logoOnAccessForm?: boolean;
annotationsEnabled?: boolean;
textSelectionEnabled?: boolean;
}) {
useDisablePrint();
const {
@@ -314,6 +316,7 @@ export default function DocumentView({
useAdvancedExcelViewer={useAdvancedExcelViewer}
viewerEmail={data.email ?? verifiedEmail ?? userEmail ?? undefined}
annotationsEnabled={annotationsEnabled}
textSelectionEnabled={textSelectionEnabled}
/>
) : (
<div className="flex h-screen items-center justify-center">
+3
View File
@@ -66,6 +66,7 @@ export default function ViewData({
dataroomId,
canDownload,
annotationsEnabled,
textSelectionEnabled,
}: {
viewData: DEFAULT_DOCUMENT_VIEW_TYPE | DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE;
link: LinkWithDocument | LinkWithDataroomDocument;
@@ -83,6 +84,7 @@ export default function ViewData({
dataroomId?: string;
canDownload?: boolean;
annotationsEnabled?: boolean;
textSelectionEnabled?: boolean;
}) {
const { isMobile } = useMediaQuery();
@@ -136,6 +138,7 @@ export default function ViewData({
versionNumber={document.versions[0].versionNumber}
theme={notionData.theme}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
textSelectionEnabled={textSelectionEnabled ?? false}
navData={navData}
/>
) : viewData.fileType === "link" ? (
+3
View File
@@ -128,12 +128,14 @@ export const NotionPage = ({
versionNumber,
theme,
screenshotProtectionEnabled,
textSelectionEnabled,
navData,
}: {
recordMap: ExtendedRecordMap;
versionNumber: number;
theme?: NotionTheme | null;
screenshotProtectionEnabled: boolean;
textSelectionEnabled: boolean;
navData: TNavData;
}) => {
const { isPreview, linkId, documentId, viewId, brand } = navData;
@@ -543,6 +545,7 @@ export const NotionPage = ({
!isWindowFocused &&
screenshotProtectionEnabled &&
"blur-xl transition-all duration-300",
textSelectionEnabled && "notion-text-selection-enabled",
)}
>
<NotionRenderer
+3 -1
View File
@@ -15,7 +15,8 @@ export type BetaFeatures =
| "dataroomInvitations"
| "workflows"
| "ai"
| "sso";
| "sso"
| "textSelection";
type BetaFeaturesRecord = Record<BetaFeatures, string[]>;
@@ -36,6 +37,7 @@ export const getFeatureFlags = async ({ teamId }: { teamId?: string }) => {
workflows: false,
ai: false,
sso: false,
textSelection: false,
};
// Return all features as false if edge config is not available
+9
View File
@@ -12,6 +12,7 @@ import { parsePageId } from "notion-utils";
import z from "zod";
import { fetchLinkDataById } from "@/lib/api/links/link-data";
import { getFeatureFlags } from "@/lib/featureFlags";
import notion from "@/lib/notion";
import {
addSignedUrls,
@@ -50,6 +51,7 @@ type DataroomDocumentProps = {
useAdvancedExcelViewer: boolean;
useCustomAccessForm: boolean;
logoOnAccessForm: boolean;
textSelectionEnabled?: boolean;
error?: boolean;
};
@@ -62,6 +64,7 @@ export default function DataroomDocumentViewPage({
useAdvancedExcelViewer,
useCustomAccessForm,
logoOnAccessForm,
textSelectionEnabled,
error,
}: DataroomDocumentProps) {
const router = useRouter();
@@ -183,6 +186,7 @@ export default function DataroomDocumentViewPage({
token={storedToken}
verifiedEmail={verifiedEmail}
preview={!!preview}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
@@ -251,6 +255,10 @@ export async function getStaticProps(context: GetStaticPropsContext) {
const { advancedExcelEnabled, ...linkDocument } =
linkData.dataroomDocument.document;
// Check feature flags
const featureFlags = await getFeatureFlags({ teamId: teamId || undefined });
const textSelectionEnabled = featureFlags.textSelection;
return {
props: {
linkData: {
@@ -291,6 +299,7 @@ export async function getStaticProps(context: GetStaticPropsContext) {
teamId === "cm9ztf0s70005js04i689gefn" ||
teamId === "cmk2hnmqh0000k304zcoezt6n",
logoOnAccessForm: teamId === "cm7nlkrhm0000qgh0nvyrrywr",
textSelectionEnabled,
},
revalidate: brand || recordMap ? 10 : 60,
};
+8
View File
@@ -72,6 +72,7 @@ export interface ViewPageProps {
logoOnAccessForm: boolean;
dataroomIndexEnabled?: boolean;
annotationsEnabled?: boolean;
textSelectionEnabled?: boolean;
}
export const getStaticProps = async (context: GetStaticPropsContext) => {
@@ -181,6 +182,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
// Check feature flags for document links
const featureFlags = await getFeatureFlags({ teamId });
const annotationsEnabled = featureFlags.annotations;
const textSelectionEnabled = featureFlags.textSelection;
return {
props: {
@@ -222,6 +224,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
teamId === "cm7nlkrhm0000qgh0nvyrrywr" ||
teamId === "clup33by90000oewh4rfvp2eg",
annotationsEnabled,
textSelectionEnabled,
},
revalidate: brand || recordMap ? 10 : 60,
};
@@ -259,6 +262,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
const featureFlags = await getFeatureFlags({ teamId });
const dataroomIndexEnabled = featureFlags.dataroomIndex;
const annotationsEnabled = featureFlags.annotations;
const textSelectionEnabled = featureFlags.textSelection;
const lastUpdatedAt = link.dataroom.documents.reduce(
(max: number, doc: any) => {
@@ -307,6 +311,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
teamId === "clup33by90000oewh4rfvp2eg",
dataroomIndexEnabled,
annotationsEnabled,
textSelectionEnabled,
},
revalidate: 10,
};
@@ -336,6 +341,7 @@ export default function ViewPage({
logoOnAccessForm,
dataroomIndexEnabled,
annotationsEnabled,
textSelectionEnabled,
error,
notionError,
}: ViewPageProps & { error?: boolean; notionError?: boolean }) {
@@ -488,6 +494,7 @@ export default function ViewPage({
token={storedToken}
verifiedEmail={verifiedEmail}
annotationsEnabled={annotationsEnabled}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
@@ -568,6 +575,7 @@ export default function ViewPage({
previewToken={previewToken}
preview={!!preview}
dataroomIndexEnabled={dataroomIndexEnabled}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
@@ -12,6 +12,7 @@ import { parsePageId } from "notion-utils";
import z from "zod";
import { fetchLinkDataByDomainSlug } from "@/lib/api/links/link-data";
import { getFeatureFlags } from "@/lib/featureFlags";
import notion from "@/lib/notion";
import {
addSignedUrls,
@@ -50,6 +51,7 @@ type DataroomDocumentProps = {
useAdvancedExcelViewer: boolean;
useCustomAccessForm: boolean;
logoOnAccessForm: boolean;
textSelectionEnabled?: boolean;
error?: boolean;
};
@@ -62,6 +64,7 @@ export default function DataroomDocumentViewPage({
useAdvancedExcelViewer,
useCustomAccessForm,
logoOnAccessForm,
textSelectionEnabled,
error,
}: DataroomDocumentProps) {
const router = useRouter();
@@ -184,6 +187,7 @@ export default function DataroomDocumentViewPage({
verifiedEmail={verifiedEmail}
preview={!!preview}
logoOnAccessForm={logoOnAccessForm}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
@@ -261,6 +265,10 @@ export async function getStaticProps(context: GetStaticPropsContext) {
const { advancedExcelEnabled, ...linkDocument } =
linkData.dataroomDocument.document;
// Check feature flags
const featureFlags = await getFeatureFlags({ teamId: teamId || undefined });
const textSelectionEnabled = featureFlags.textSelection;
return {
props: {
linkData: {
@@ -301,6 +309,7 @@ export async function getStaticProps(context: GetStaticPropsContext) {
teamId === "cm9ztf0s70005js04i689gefn" ||
teamId === "cmk2hnmqh0000k304zcoezt6n",
logoOnAccessForm: teamId === "cm7nlkrhm0000qgh0nvyrrywr",
textSelectionEnabled,
},
revalidate: brand || recordMap ? 10 : 60,
};
+12 -1
View File
@@ -154,6 +154,10 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
link.document;
const teamPlan = team?.plan || "free";
// Check feature flags for document links
const docFeatureFlags = await getFeatureFlags({ teamId: teamId || undefined });
const textSelectionEnabled = docFeatureFlags.textSelection;
return {
props: {
linkData: {
@@ -192,6 +196,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
logoOnAccessForm:
teamId === "cm7nlkrhm0000qgh0nvyrrywr" ||
teamId === "clup33by90000oewh4rfvp2eg",
textSelectionEnabled,
},
revalidate: 10,
};
@@ -225,9 +230,10 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
const { teamId } = link.dataroom;
// Check if dataroomIndex feature flag is enabled
// Check feature flags
const featureFlags = await getFeatureFlags({ teamId });
const dataroomIndexEnabled = featureFlags.dataroomIndex;
const textSelectionEnabled = featureFlags.textSelection;
const lastUpdatedAt = link.dataroom.documents.reduce(
(max: number, doc: any) => {
@@ -275,6 +281,7 @@ export const getStaticProps = async (context: GetStaticPropsContext) => {
teamId === "cm7nlkrhm0000qgh0nvyrrywr" ||
teamId === "clup33by90000oewh4rfvp2eg",
dataroomIndexEnabled,
textSelectionEnabled,
},
revalidate: 10,
};
@@ -302,6 +309,7 @@ export default function ViewPage({
useCustomAccessForm,
logoOnAccessForm,
dataroomIndexEnabled,
textSelectionEnabled,
error,
}: {
linkData: DocumentLinkData | DataroomLinkData | WorkflowLinkData;
@@ -323,6 +331,7 @@ export default function ViewPage({
useCustomAccessForm: boolean;
logoOnAccessForm: boolean;
dataroomIndexEnabled?: boolean;
textSelectionEnabled?: boolean;
error?: boolean;
}) {
const router = useRouter();
@@ -469,6 +478,7 @@ export default function ViewPage({
token={storedToken}
verifiedEmail={verifiedEmail}
logoOnAccessForm={logoOnAccessForm}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
@@ -549,6 +559,7 @@ export default function ViewPage({
preview={!!preview}
logoOnAccessForm={logoOnAccessForm}
dataroomIndexEnabled={dataroomIndexEnabled}
textSelectionEnabled={textSelectionEnabled}
/>
</>
);
+32 -1
View File
@@ -72,7 +72,7 @@
}
}
/* Disable user selection for all text content */
/* Disable user selection for all text content (default) */
.notion-page,
.notion-text,
.notion-h1,
@@ -103,6 +103,37 @@
user-select: none;
}
/* Re-enable user selection when text selection is allowed */
.notion-text-selection-enabled .notion-page,
.notion-text-selection-enabled .notion-text,
.notion-text-selection-enabled .notion-h1,
.notion-text-selection-enabled .notion-h2,
.notion-text-selection-enabled .notion-h3,
.notion-text-selection-enabled .notion-h4,
.notion-text-selection-enabled .notion-h5,
.notion-text-selection-enabled .notion-h6,
.notion-text-selection-enabled .notion-header,
.notion-text-selection-enabled .notion-sub_header,
.notion-text-selection-enabled .notion-sub_sub_header,
.notion-text-selection-enabled .notion-quote,
.notion-text-selection-enabled .notion-callout,
.notion-text-selection-enabled .notion-toggle,
.notion-text-selection-enabled .notion-list,
.notion-text-selection-enabled .notion-numbered-list,
.notion-text-selection-enabled .notion-to_do,
.notion-text-selection-enabled .notion-code,
.notion-text-selection-enabled .notion-simple-table,
.notion-text-selection-enabled .notion-table,
.notion-text-selection-enabled .notion-column-list,
.notion-text-selection-enabled .notion-bookmark,
.notion-text-selection-enabled .notion-embed,
.notion-text-selection-enabled .notion-database {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
/* Disable image dragging */
.notion-image,
.notion-asset,