ncblock 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOST.md +68 -0
- package/dist/bridge/SandboxBridge.d.ts.map +1 -1
- package/dist/bridge/context.d.ts.map +1 -1
- package/dist/bridge/dataSources/dataSource.d.ts.map +1 -1
- package/dist/bridge/dataSources/dataSourcePage.d.ts.map +1 -1
- package/dist/bridge/dataSources/dataSourceValue.d.ts.map +1 -1
- package/dist/bridge/dataSources/dateValue.d.ts.map +1 -1
- package/dist/bridge/dataSources/propertySchema.d.ts.map +1 -1
- package/dist/bridge/dataSources/recordPointer.d.ts.map +1 -1
- package/dist/bridge/dataSources/resolve.d.ts.map +1 -1
- package/dist/bridge/dataSources/resolveProperty.d.ts.map +1 -1
- package/dist/bridge/hostState.d.ts.map +1 -1
- package/dist/bridge/ids.d.ts.map +1 -1
- package/dist/bridge/incomingType.d.ts.map +1 -1
- package/dist/bridge/loadManifest.d.ts.map +1 -1
- package/dist/bridge/manifest.d.ts.map +1 -1
- package/dist/bridge/messages/contextChanged.d.ts.map +1 -1
- package/dist/bridge/messages/createPage.d.ts.map +1 -1
- package/dist/bridge/messages/createPageResult.d.ts.map +1 -1
- package/dist/bridge/messages/dataSourcesChanged.d.ts.map +1 -1
- package/dist/bridge/messages/getPage.d.ts.map +1 -1
- package/dist/bridge/messages/getUser.d.ts.map +1 -1
- package/dist/bridge/messages/hostToSandbox.d.ts.map +1 -1
- package/dist/bridge/messages/init.d.ts.map +1 -1
- package/dist/bridge/messages/invalidHostMessage.d.ts.map +1 -1
- package/dist/bridge/messages/invalidSandboxMessage.d.ts.map +1 -1
- package/dist/bridge/messages/listUsers.d.ts.map +1 -1
- package/dist/bridge/messages/queryDataSource.d.ts.map +1 -1
- package/dist/bridge/messages/queryDataSourceResult.d.ts.map +1 -1
- package/dist/bridge/messages/ready.d.ts.map +1 -1
- package/dist/bridge/messages/resize.d.ts.map +1 -1
- package/dist/bridge/messages/sandboxToHost.d.ts.map +1 -1
- package/dist/bridge/messages/themeChanged.d.ts.map +1 -1
- package/dist/bridge/messages/updatePage.d.ts.map +1 -1
- package/dist/bridge/messages/updatePageResult.d.ts.map +1 -1
- package/dist/bridge/pages/page.d.ts.map +1 -1
- package/dist/bridge/pendingRequests.d.ts.map +1 -1
- package/dist/bridge/sandboxClient.d.ts.map +1 -1
- package/dist/bridge/theme.d.ts.map +1 -1
- package/dist/bridge/users/user.d.ts.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/users.d.ts.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/docs/context.md +45 -0
- package/docs/data-sources.md +161 -0
- package/docs/lifecycle.md +92 -0
- package/docs/manifest.md +42 -0
- package/docs/pages.md +143 -0
- package/docs/users.md +61 -0
- package/package.json +12 -9
- package/src/bridge/SandboxBridge.ts +659 -0
- package/src/bridge/context.ts +58 -0
- package/src/bridge/dataSources/dataSource.ts +63 -0
- package/src/bridge/dataSources/dataSourcePage.ts +69 -0
- package/src/bridge/dataSources/dataSourceValue.ts +19 -0
- package/src/bridge/dataSources/dateValue.ts +96 -0
- package/src/bridge/dataSources/propertySchema.ts +186 -0
- package/src/bridge/dataSources/recordPointer.ts +13 -0
- package/src/bridge/dataSources/resolve.ts +96 -0
- package/src/bridge/dataSources/resolveProperty.ts +96 -0
- package/src/bridge/hostState.ts +146 -0
- package/src/bridge/ids.ts +30 -0
- package/src/bridge/incomingType.ts +19 -0
- package/src/bridge/loadManifest.ts +54 -0
- package/src/bridge/manifest.ts +53 -0
- package/src/bridge/messages/contextChanged.ts +15 -0
- package/src/bridge/messages/createPage.ts +64 -0
- package/src/bridge/messages/createPageResult.ts +25 -0
- package/src/bridge/messages/dataSourcesChanged.ts +18 -0
- package/src/bridge/messages/getPage.ts +32 -0
- package/src/bridge/messages/getUser.ts +32 -0
- package/src/bridge/messages/hostToSandbox.ts +33 -0
- package/src/bridge/messages/init.ts +20 -0
- package/src/bridge/messages/invalidHostMessage.ts +16 -0
- package/src/bridge/messages/invalidSandboxMessage.ts +18 -0
- package/src/bridge/messages/listUsers.ts +33 -0
- package/src/bridge/messages/queryDataSource.ts +16 -0
- package/src/bridge/messages/queryDataSourceResult.ts +18 -0
- package/src/bridge/messages/ready.ts +25 -0
- package/src/bridge/messages/resize.ts +13 -0
- package/src/bridge/messages/sandboxToHost.ts +30 -0
- package/src/bridge/messages/themeChanged.ts +15 -0
- package/src/bridge/messages/updatePage.ts +21 -0
- package/src/bridge/messages/updatePageResult.ts +24 -0
- package/src/bridge/pages/page.ts +314 -0
- package/src/bridge/pendingRequests.ts +28 -0
- package/src/bridge/sandboxClient.ts +112 -0
- package/src/bridge/theme.ts +5 -0
- package/src/bridge/users/user.ts +31 -0
- package/src/host.ts +67 -0
- package/src/index.ts +86 -0
- package/src/init.ts +92 -0
- package/src/react.tsx +418 -0
- package/src/types.ts +157 -0
- package/src/users.ts +26 -0
- package/src/utils.ts +13 -0
- package/vite-plugin/index.d.ts +46 -0
- package/vite-plugin/index.js +115 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entrypoint for `ncblock`.
|
|
3
|
+
*
|
|
4
|
+
* Everything re-exported here is part of the SDK's public API. Anything not
|
|
5
|
+
* re-exported is internal and may change without notice. Consumers should
|
|
6
|
+
* import exclusively from `ncblock`, never from deeper paths,
|
|
7
|
+
* so internal files (e.g. `./bridge/*`) can be refactored without breaking
|
|
8
|
+
* downstream code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type { NotionCustomBlockContext } from "./bridge/context"
|
|
12
|
+
export type {
|
|
13
|
+
NotionCollectionSchema,
|
|
14
|
+
NotionDataSource,
|
|
15
|
+
} from "./bridge/dataSources/dataSource"
|
|
16
|
+
export type {
|
|
17
|
+
NotionDataSourcePage,
|
|
18
|
+
NotionDataSourcePageUpdateInput,
|
|
19
|
+
NotionDataSourcePageUpdateResult,
|
|
20
|
+
} from "./bridge/dataSources/dataSourcePage"
|
|
21
|
+
export type { NotionDataSourceValue } from "./bridge/dataSources/dataSourceValue"
|
|
22
|
+
export type {
|
|
23
|
+
NotionDate,
|
|
24
|
+
NotionDateRange,
|
|
25
|
+
NotionDateReminder,
|
|
26
|
+
NotionDateTime,
|
|
27
|
+
NotionDateTimeRange,
|
|
28
|
+
NotionDateTimeReminder,
|
|
29
|
+
NotionDateValue,
|
|
30
|
+
NotionNoReminder,
|
|
31
|
+
NotionTimeReminder,
|
|
32
|
+
} from "./bridge/dataSources/dateValue"
|
|
33
|
+
export type {
|
|
34
|
+
NotionBuiltinPropertyId,
|
|
35
|
+
NotionDualProperty,
|
|
36
|
+
NotionPropertyColor,
|
|
37
|
+
NotionPropertyOption,
|
|
38
|
+
NotionPropertySchema,
|
|
39
|
+
NotionPropertyType,
|
|
40
|
+
NotionStatusGroup,
|
|
41
|
+
} from "./bridge/dataSources/propertySchema"
|
|
42
|
+
export {
|
|
43
|
+
NOTION_BUILTIN_PROPERTY_IDS,
|
|
44
|
+
NOTION_PROPERTY_TYPES,
|
|
45
|
+
} from "./bridge/dataSources/propertySchema"
|
|
46
|
+
export type { NotionRecordPointer } from "./bridge/dataSources/recordPointer"
|
|
47
|
+
export type { NotionDataSourceId, NotionSpaceId } from "./bridge/ids"
|
|
48
|
+
export type {
|
|
49
|
+
CustomBlockManifest,
|
|
50
|
+
ManifestDataSource,
|
|
51
|
+
ManifestIcon,
|
|
52
|
+
ManifestProperty,
|
|
53
|
+
} from "./bridge/manifest"
|
|
54
|
+
export type { NotionCreatePagePosition } from "./bridge/messages/createPage"
|
|
55
|
+
export type {
|
|
56
|
+
NotionPage,
|
|
57
|
+
NotionPageCover,
|
|
58
|
+
NotionPageIcon,
|
|
59
|
+
NotionPageId,
|
|
60
|
+
NotionPageParent,
|
|
61
|
+
NotionPagePropertyInputMap,
|
|
62
|
+
NotionPagePropertyInputValue,
|
|
63
|
+
NotionPagePropertyValue,
|
|
64
|
+
NotionPagePropertyWriteMap,
|
|
65
|
+
} from "./bridge/pages/page"
|
|
66
|
+
export { pages } from "./bridge/sandboxClient"
|
|
67
|
+
export type { NotionTheme } from "./bridge/theme"
|
|
68
|
+
export {
|
|
69
|
+
type CustomBlockInitial,
|
|
70
|
+
type InitCustomBlockOptions,
|
|
71
|
+
initCustomBlock,
|
|
72
|
+
NotInIframeError,
|
|
73
|
+
} from "./init"
|
|
74
|
+
export {
|
|
75
|
+
NotionCustomBlock,
|
|
76
|
+
type NotionCustomBlockProps,
|
|
77
|
+
type UseCustomBlockInitResult,
|
|
78
|
+
useCustomBlockAutoResize,
|
|
79
|
+
useCustomBlockContext,
|
|
80
|
+
useCustomBlockInit,
|
|
81
|
+
useDataSource,
|
|
82
|
+
useDataSourceDefinitions,
|
|
83
|
+
useTheme,
|
|
84
|
+
} from "./react"
|
|
85
|
+
export * from "./types"
|
|
86
|
+
export { users } from "./users"
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { NotionCustomBlockContext } from "./bridge/context"
|
|
2
|
+
import type { NotionDataSource } from "./bridge/dataSources/dataSource"
|
|
3
|
+
import { loadManifest } from "./bridge/loadManifest"
|
|
4
|
+
import {
|
|
5
|
+
awaitCustomBlockInit,
|
|
6
|
+
getCustomBlockHostState,
|
|
7
|
+
sendCustomBlockReady,
|
|
8
|
+
} from "./bridge/sandboxClient"
|
|
9
|
+
import type { NotionTheme } from "./bridge/theme"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The payload sent by the host in the `init` message in response to the sandbox's `ready` message.
|
|
13
|
+
*/
|
|
14
|
+
export type CustomBlockInitial = {
|
|
15
|
+
theme: NotionTheme
|
|
16
|
+
context: NotionCustomBlockContext
|
|
17
|
+
dataSources: NotionDataSource[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Error thrown when the SDK is loaded in a top-level standalone window with no parent frame.
|
|
22
|
+
* `postMessage` would just hit the same window and the handshake can never complete.
|
|
23
|
+
* `<NotionCustomBlock>` catches this specifically and falls back to a standalone preview with a
|
|
24
|
+
* warning banner. Direct callers can `instanceof` it to apply their own policy.
|
|
25
|
+
*/
|
|
26
|
+
export class NotInIframeError extends Error {
|
|
27
|
+
constructor(message: string = NOT_IN_IFRAME_MESSAGE) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = "NotInIframeError"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Options for {@link initCustomBlock}.
|
|
35
|
+
*/
|
|
36
|
+
export type InitCustomBlockOptions = {
|
|
37
|
+
/**
|
|
38
|
+
* How long to wait for the host's `init` response before rejecting with an error.
|
|
39
|
+
*
|
|
40
|
+
* @default 2000 - Short enough that a misconfigured embed surfaces quickly, long enough to
|
|
41
|
+
* absorb a real host's worst-case init latency.
|
|
42
|
+
*/
|
|
43
|
+
timeoutMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_INIT_TIMEOUT_MS = 2000
|
|
47
|
+
|
|
48
|
+
const NOT_IN_IFRAME_MESSAGE =
|
|
49
|
+
"<NotionCustomBlock> only works inside an iframe — use the dev shell or deploy to Notion."
|
|
50
|
+
|
|
51
|
+
let initPromise: Promise<CustomBlockInitial> | undefined
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Performs the SDK <-> host handshake: loads `custom_blocks.json`, posts
|
|
55
|
+
* `ready`, then awaits the host's `init` message. Resolves with that payload.
|
|
56
|
+
*
|
|
57
|
+
* Rejects with a `TimeoutError` if the host doesn't respond inside `timeoutMs`.
|
|
58
|
+
*
|
|
59
|
+
* Idempotent: subsequent calls return the same promise as the first and ignore any new options.
|
|
60
|
+
* Mount your React tree (or call any SDK hook / `subscribeToCustomBlockHost`) only after the
|
|
61
|
+
* returned promise resolves.
|
|
62
|
+
*/
|
|
63
|
+
export function initCustomBlock(
|
|
64
|
+
opts: InitCustomBlockOptions = {},
|
|
65
|
+
): Promise<CustomBlockInitial> {
|
|
66
|
+
initPromise ??= (async () => {
|
|
67
|
+
// Fail fast with a typed error when rendered as a standalone tab and not in a parent frame.
|
|
68
|
+
// Otherwise, it would eventually hit the timeout, since `postMessage` to `window.parent`
|
|
69
|
+
// would just hit the same window and never arrive.
|
|
70
|
+
if (typeof window !== "undefined" && window.parent === window) {
|
|
71
|
+
throw new NotInIframeError()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Load the manifest and send it to the host.
|
|
75
|
+
const manifest = await loadManifest()
|
|
76
|
+
|
|
77
|
+
sendCustomBlockReady(manifest)
|
|
78
|
+
|
|
79
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_INIT_TIMEOUT_MS
|
|
80
|
+
const message = await awaitCustomBlockInit(AbortSignal.timeout(timeoutMs))
|
|
81
|
+
|
|
82
|
+
const hostState = getCustomBlockHostState()
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
theme: message.theme,
|
|
86
|
+
context: message.context as NotionCustomBlockContext,
|
|
87
|
+
dataSources:
|
|
88
|
+
hostState.status === "initialized" ? hostState.dataSources : [],
|
|
89
|
+
}
|
|
90
|
+
})()
|
|
91
|
+
return initPromise
|
|
92
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
} from "react"
|
|
8
|
+
import type { NotionCustomBlockContext } from "./bridge/context"
|
|
9
|
+
import type { NotionDataSource } from "./bridge/dataSources/dataSource"
|
|
10
|
+
import type { CustomBlockHostState } from "./bridge/hostState"
|
|
11
|
+
import {
|
|
12
|
+
getCustomBlockHostState,
|
|
13
|
+
getDataSourceQueryView,
|
|
14
|
+
postCustomBlockResize,
|
|
15
|
+
queryCustomBlockDataSource,
|
|
16
|
+
setMockCustomBlockState,
|
|
17
|
+
subscribeToCustomBlockHost,
|
|
18
|
+
} from "./bridge/sandboxClient"
|
|
19
|
+
import type { NotionTheme } from "./bridge/theme"
|
|
20
|
+
import {
|
|
21
|
+
type CustomBlockInitial,
|
|
22
|
+
type InitCustomBlockOptions,
|
|
23
|
+
initCustomBlock,
|
|
24
|
+
NotInIframeError,
|
|
25
|
+
} from "./init"
|
|
26
|
+
import type { UseDataSourceResult } from "./types"
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DATA_SOURCE_QUERY_LIMIT = 20
|
|
29
|
+
|
|
30
|
+
function useCustomBlockHost() {
|
|
31
|
+
return useSyncExternalStore(
|
|
32
|
+
subscribeToCustomBlockHost,
|
|
33
|
+
getCustomBlockHostState,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertInitialized(
|
|
38
|
+
host: CustomBlockHostState,
|
|
39
|
+
hookName: string,
|
|
40
|
+
): asserts host is Extract<CustomBlockHostState, { status: "initialized" }> {
|
|
41
|
+
if (host.status !== "initialized") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${hookName} called before \`initCustomBlock\` resolved. Await it before mounting your tree.`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Discriminated state returned by {@link useCustomBlockInit}.
|
|
50
|
+
*
|
|
51
|
+
* Branch on `isLoaded`/`error`:
|
|
52
|
+
* - `{ isLoaded: false, error: undefined }` — handshake in progress.
|
|
53
|
+
* - `{ isLoaded: false, error: Error }` — handshake failed (most commonly a
|
|
54
|
+
* `TimeoutError` because the host never sent `init`).
|
|
55
|
+
* - `{ isLoaded: true, initial }` — handshake complete; safe to render
|
|
56
|
+
* children that call `useTheme`, `useCustomBlockContext`, etc.
|
|
57
|
+
*/
|
|
58
|
+
export type UseCustomBlockInitResult =
|
|
59
|
+
| { isLoaded: false; error: undefined }
|
|
60
|
+
| { isLoaded: false; error: Error }
|
|
61
|
+
| { isLoaded: true; error: undefined; initial: CustomBlockInitial }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* React wrapper around {@link initCustomBlock}. Kicks off the SDK ↔ host
|
|
65
|
+
* handshake on mount and returns a discriminated state object so the rest of
|
|
66
|
+
* the tree can render inside the `isLoaded === true` branch (where every
|
|
67
|
+
* other SDK hook is guaranteed to return a populated value).
|
|
68
|
+
*
|
|
69
|
+
* Idempotent — multiple components can call this; they share the same
|
|
70
|
+
* underlying handshake promise.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* function Root() {
|
|
74
|
+
* const init = useCustomBlockInit()
|
|
75
|
+
* if (init.error) return <p role="alert">Init failed: {init.error.message}</p>
|
|
76
|
+
* if (!init.isLoaded) return null
|
|
77
|
+
* return <App />
|
|
78
|
+
* }
|
|
79
|
+
*/
|
|
80
|
+
export function useCustomBlockInit(
|
|
81
|
+
opts?: InitCustomBlockOptions,
|
|
82
|
+
): UseCustomBlockInitResult {
|
|
83
|
+
const [state, setState] = useState<UseCustomBlockInitResult>({
|
|
84
|
+
isLoaded: false,
|
|
85
|
+
error: undefined,
|
|
86
|
+
})
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false
|
|
89
|
+
initCustomBlock(opts).then(
|
|
90
|
+
initial => {
|
|
91
|
+
if (!cancelled) {
|
|
92
|
+
setState({ isLoaded: true, error: undefined, initial })
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
err => {
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
setState({
|
|
98
|
+
isLoaded: false,
|
|
99
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true
|
|
106
|
+
}
|
|
107
|
+
// `initCustomBlock` caches its result, so options after the first call
|
|
108
|
+
// are ignored — re-running on opts changes would be misleading.
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, [])
|
|
111
|
+
return state
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Props accepted by {@link NotionCustomBlock}.
|
|
116
|
+
*/
|
|
117
|
+
export type NotionCustomBlockProps = InitCustomBlockOptions & {
|
|
118
|
+
children: ReactNode
|
|
119
|
+
/**
|
|
120
|
+
* Rendered while the SDK ↔ host handshake is in progress. Defaults to
|
|
121
|
+
* `null` (nothing).
|
|
122
|
+
*/
|
|
123
|
+
fallback?: ReactNode
|
|
124
|
+
/**
|
|
125
|
+
* Rendered when the handshake fails. Either a node, or a function that
|
|
126
|
+
* receives the `Error`. When omitted, a small inline `<p role="alert">` is
|
|
127
|
+
* rendered with the error message — replace it for production templates.
|
|
128
|
+
*/
|
|
129
|
+
errorFallback?: ReactNode | ((error: Error) => ReactNode)
|
|
130
|
+
/**
|
|
131
|
+
* Whether the provider should automatically post resize messages so the
|
|
132
|
+
* host iframe matches the content height of `#root`. Defaults to `true`.
|
|
133
|
+
* Pass `false` when you want to use the default block size and are ok
|
|
134
|
+
* with scrollbars within the Notion client.
|
|
135
|
+
*
|
|
136
|
+
* @default true
|
|
137
|
+
*/
|
|
138
|
+
autoResize?: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Top-level wrapper that runs the SDK ↔ host handshake and gates `children`
|
|
143
|
+
* until it resolves. Passes `timeoutMs` straight through to
|
|
144
|
+
* {@link initCustomBlock}.
|
|
145
|
+
*
|
|
146
|
+
* Templates that prefer not to write a `Root` gating component (or top-level
|
|
147
|
+
* `await`) can mount their app entirely inside this provider:
|
|
148
|
+
*
|
|
149
|
+
* ```tsx
|
|
150
|
+
* ReactDOM.createRoot(root).render(
|
|
151
|
+
* <NotionCustomBlock>
|
|
152
|
+
* <App />
|
|
153
|
+
* </NotionCustomBlock>,
|
|
154
|
+
* )
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* Inside `children`, every SDK hook is guaranteed to return a populated
|
|
158
|
+
* value. Outside the provider (or during the loading window), they throw.
|
|
159
|
+
*/
|
|
160
|
+
export function NotionCustomBlock({
|
|
161
|
+
children,
|
|
162
|
+
timeoutMs,
|
|
163
|
+
fallback = null,
|
|
164
|
+
errorFallback,
|
|
165
|
+
autoResize = true,
|
|
166
|
+
}: NotionCustomBlockProps) {
|
|
167
|
+
const init = useCustomBlockInit({ timeoutMs })
|
|
168
|
+
useCustomBlockAutoResize({ enabled: autoResize })
|
|
169
|
+
const isStandalone = init.error instanceof NotInIframeError
|
|
170
|
+
const host = useCustomBlockHost()
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!isStandalone) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
console.warn(`[notion-custom-sdk] ${init.error?.message}`)
|
|
177
|
+
setMockCustomBlockState({
|
|
178
|
+
type: "init",
|
|
179
|
+
theme: "light",
|
|
180
|
+
context: {
|
|
181
|
+
customBlockId: "",
|
|
182
|
+
parent: { id: "", type: "" },
|
|
183
|
+
page: { id: "" },
|
|
184
|
+
},
|
|
185
|
+
dataSources: { bindings: {} },
|
|
186
|
+
})
|
|
187
|
+
}, [isStandalone, init.error])
|
|
188
|
+
|
|
189
|
+
if (init.error && !isStandalone) {
|
|
190
|
+
if (errorFallback === undefined) {
|
|
191
|
+
return (
|
|
192
|
+
<p role="alert">Notion custom view init failed: {init.error.message}</p>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
{typeof errorFallback === "function"
|
|
198
|
+
? errorFallback(init.error)
|
|
199
|
+
: errorFallback}
|
|
200
|
+
</>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
if (isStandalone) {
|
|
204
|
+
// Wait for the effect to seed placeholder host state; otherwise hooks
|
|
205
|
+
// in `children` would throw.
|
|
206
|
+
if (host.status !== "initialized") {
|
|
207
|
+
return <>{fallback}</>
|
|
208
|
+
}
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<div role="status" style={STANDALONE_BANNER_STYLE}>
|
|
212
|
+
Notion host not detected — running in standalone preview. SDK hooks
|
|
213
|
+
return placeholder values until embedded in Notion.
|
|
214
|
+
</div>
|
|
215
|
+
{children}
|
|
216
|
+
</>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (!init.isLoaded) {
|
|
220
|
+
return <>{fallback}</>
|
|
221
|
+
}
|
|
222
|
+
return <>{children}</>
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const STANDALONE_BANNER_STYLE = {
|
|
226
|
+
padding: "8px 12px",
|
|
227
|
+
background: "#fff8e1",
|
|
228
|
+
color: "#5d4200",
|
|
229
|
+
borderBottom: "1px solid #f0d77b",
|
|
230
|
+
fontSize: 13,
|
|
231
|
+
fontFamily: "system-ui, sans-serif",
|
|
232
|
+
lineHeight: 1.4,
|
|
233
|
+
} as const
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Returns the block's location in the document tree. Re-renders when the host sends
|
|
237
|
+
* `contextChanged` (e.g. the block moves into a different container or its enclosing
|
|
238
|
+
* page changes).
|
|
239
|
+
*
|
|
240
|
+
* Throws if called before `initCustomBlock` has resolved — `await` it before mounting.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* const ctx = useCustomBlockContext()
|
|
244
|
+
* console.log(ctx.customBlockId, ctx.parent.type)
|
|
245
|
+
*/
|
|
246
|
+
export function useCustomBlockContext(): NotionCustomBlockContext {
|
|
247
|
+
const host = useCustomBlockHost()
|
|
248
|
+
assertInitialized(host, "useCustomBlockContext")
|
|
249
|
+
return host.context
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Returns the host's current theme. Re-renders on every `themeChanged` message from the host.
|
|
254
|
+
*
|
|
255
|
+
* Throws if called before `initCustomBlock` has resolved — `await` it before mounting.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* const theme = useTheme()
|
|
259
|
+
*/
|
|
260
|
+
export function useTheme(): NotionTheme {
|
|
261
|
+
const host = useCustomBlockHost()
|
|
262
|
+
assertInitialized(host, "useTheme")
|
|
263
|
+
return host.theme
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Returns the raw data-source definitions — semantic keys plus optional `collectionPointer`,
|
|
268
|
+
* `collectionSchema`, `propertyIdsByKey`, and derived `propertySchemasById`. Most templates should use
|
|
269
|
+
* `useDataSource(key)` instead — this hook is for views that render the configuration
|
|
270
|
+
* itself (debug panels, schema-driven UIs).
|
|
271
|
+
*
|
|
272
|
+
* Throws if called before `initCustomBlock` has resolved.
|
|
273
|
+
*/
|
|
274
|
+
export function useDataSourceDefinitions(): NotionDataSource[] {
|
|
275
|
+
const host = useCustomBlockHost()
|
|
276
|
+
assertInitialized(host, "useDataSourceDefinitions")
|
|
277
|
+
return host.dataSources
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reads from the data source mapped to the given semantic `key`.
|
|
282
|
+
*
|
|
283
|
+
* The first render kicks off a query for the first `initialLimit` items. Calling
|
|
284
|
+
* `fetchMore()` re-requests a larger prefix (the bridge does not expose cursors yet);
|
|
285
|
+
* page growth tracks `initialLimit`. The hook automatically resets to `initialLimit`
|
|
286
|
+
* when the underlying data source definition changes.
|
|
287
|
+
*
|
|
288
|
+
* @param key - The semantic data-source key the block is wired to (e.g. `"people"`).
|
|
289
|
+
* @param initialLimit - Page size for the first query, and the increment used by
|
|
290
|
+
* `fetchMore`. Defaults to 20.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* const { items, isLoading, hasMore, fetchMore, error } = useDataSource("default")
|
|
294
|
+
*/
|
|
295
|
+
export function useDataSource(
|
|
296
|
+
key: string,
|
|
297
|
+
initialLimit: number = DEFAULT_DATA_SOURCE_QUERY_LIMIT,
|
|
298
|
+
): UseDataSourceResult {
|
|
299
|
+
const host = useCustomBlockHost()
|
|
300
|
+
const [limit, setLimit] = useState(initialLimit)
|
|
301
|
+
const matchingDataSource =
|
|
302
|
+
host.status === "initialized"
|
|
303
|
+
? host.dataSources.find(dataSource => dataSource.key === key)
|
|
304
|
+
: undefined
|
|
305
|
+
// Serialize to avoid re-querying when `dataSourcesChanged` rebuilds the array with equal entries.
|
|
306
|
+
const matchingSignature = matchingDataSource
|
|
307
|
+
? JSON.stringify(matchingDataSource)
|
|
308
|
+
: null
|
|
309
|
+
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
// A new key or source definition means we should restart from the initial page size.
|
|
312
|
+
setLimit(initialLimit)
|
|
313
|
+
}, [matchingSignature, key, initialLimit])
|
|
314
|
+
|
|
315
|
+
const isInitialized = host.status === "initialized"
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (!isInitialized) {
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
queryCustomBlockDataSource(key, limit)
|
|
322
|
+
}, [matchingSignature, isInitialized, key, limit])
|
|
323
|
+
|
|
324
|
+
const view = getDataSourceQueryView(host, key)
|
|
325
|
+
const fetchMore = useCallback(() => {
|
|
326
|
+
if (!view.hasMore || view.isLoading) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The bridge does not expose cursors yet, so "fetch more" means re-requesting a
|
|
331
|
+
// larger prefix of items and letting the host return the expanded result set.
|
|
332
|
+
// Page growth tracks the caller-provided `initialLimit` so templates can opt
|
|
333
|
+
// into larger batches.
|
|
334
|
+
setLimit(currentLimit => currentLimit + initialLimit)
|
|
335
|
+
}, [view.hasMore, view.isLoading, initialLimit])
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
items: view.items,
|
|
339
|
+
collectionSchema: view.collectionSchema,
|
|
340
|
+
propertySchemasById: view.propertySchemasById,
|
|
341
|
+
propertySchemasByKey: view.propertySchemasByKey,
|
|
342
|
+
isLoading: view.isLoading,
|
|
343
|
+
hasMore: view.hasMore,
|
|
344
|
+
fetchMore,
|
|
345
|
+
error: view.error,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Measures the sandbox's `#root` element and posts `resize` messages so the host iframe
|
|
351
|
+
* matches the block's content height. Uses `Math.ceil(scrollHeight)` and dedupes
|
|
352
|
+
* unchanged values.
|
|
353
|
+
*
|
|
354
|
+
* `<NotionCustomBlock>` calls this hook for you by default — only reach for it directly
|
|
355
|
+
* when you need to drive `enabled` yourself (e.g. behind a debug toggle). In that case,
|
|
356
|
+
* pass `autoResize={false}` to the provider to avoid running it twice. For full-bleed
|
|
357
|
+
* views that should fill their slot, pass `autoResize={false}` and skip the hook.
|
|
358
|
+
*
|
|
359
|
+
* @param args.enabled - When `false`, suspends measurement. Useful for conditional
|
|
360
|
+
* measurement (e.g. while a debug toggle is off). Defaults to `true`.
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* <NotionCustomBlock autoResize={false}>
|
|
364
|
+
* <App />
|
|
365
|
+
* </NotionCustomBlock>
|
|
366
|
+
*
|
|
367
|
+
* function App() {
|
|
368
|
+
* const [enabled, setEnabled] = useState(true)
|
|
369
|
+
* useCustomBlockAutoResize({ enabled })
|
|
370
|
+
* return <div>…</div>
|
|
371
|
+
* }
|
|
372
|
+
*/
|
|
373
|
+
export function useCustomBlockAutoResize(
|
|
374
|
+
args: {
|
|
375
|
+
/**
|
|
376
|
+
* Whether or not the hook is enabled. To disable this behavior, pass `false`. This is
|
|
377
|
+
* provided as an argument to allow for conditional disabling of the hook.
|
|
378
|
+
*
|
|
379
|
+
* @default true
|
|
380
|
+
*/
|
|
381
|
+
enabled?: boolean
|
|
382
|
+
} = {},
|
|
383
|
+
): void {
|
|
384
|
+
const { enabled = true } = args
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
if (!enabled) {
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (typeof window === "undefined") {
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const target = document.getElementById("root")
|
|
393
|
+
if (!(target instanceof HTMLElement)) {
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let lastHeight = -1
|
|
398
|
+
const post = () => {
|
|
399
|
+
const next = Math.ceil(target.scrollHeight)
|
|
400
|
+
if (next === lastHeight) {
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
lastHeight = next
|
|
404
|
+
postCustomBlockResize(next)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
post()
|
|
408
|
+
|
|
409
|
+
if (typeof ResizeObserver === "undefined") {
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
const observer = new ResizeObserver(post)
|
|
413
|
+
observer.observe(target)
|
|
414
|
+
return () => {
|
|
415
|
+
observer.disconnect()
|
|
416
|
+
}
|
|
417
|
+
}, [enabled])
|
|
418
|
+
}
|