@unitninety/site-kit 0.0.0-stage → 1.6.2
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/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @unitninety/site-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The library a UnitNinety site is built on. It reads the site's content from UnitNinety, renders its editable parts, and answers the platform at `/api/u90`, so the site's clients can edit it in UnitNinety's dashboard.
|
|
4
|
+
|
|
5
|
+
You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx --yes @unitninety/u90@1.0.1 --version
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
UnitNinety imports packages made for that version only, so keep the version the tool names.
|
|
12
|
+
|
|
13
|
+
Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
|
|
14
|
+
|
|
15
|
+
### Immediate collection-list previews
|
|
16
|
+
|
|
17
|
+
Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
|
|
18
|
+
cards should reorder and disappear immediately in Canvas. Pass the complete value, including
|
|
19
|
+
its draft `$path`, and render one card per entry:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
<Entries value={projects} className="project-grid" empty={<p>No projects yet.</p>}>
|
|
23
|
+
{entry => <ProjectCard entry={entry} />}
|
|
24
|
+
</Entries>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The card renderer runs on the server. In draft mode React owns the keyed cards and preserves
|
|
28
|
+
pending order/removal through slow refreshes; rejected saves reconcile to the server result.
|
|
29
|
+
Published output is an ordinary div and server-rendered cards. Newly added entries whose markup
|
|
30
|
+
is not already present appear when the save's server refresh arrives. Query results rendered
|
|
31
|
+
with a plain `.map()` still work, but wait for that refresh to change their order or membership.
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.6.2",
|
|
4
|
+
"description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/site/index.ts",
|
|
8
|
+
"./server": "./src/server/index.ts",
|
|
9
|
+
"./features/server": "./src/features/server/index.ts",
|
|
10
|
+
"./features/contracts": "./src/features/contracts/index.ts",
|
|
11
|
+
"./preview-lock": "./src/preview-lock/index.ts",
|
|
12
|
+
"./preview-lock/server": "./src/preview-lock/server.ts",
|
|
13
|
+
"./preview-lock/screen": "./src/preview-lock/screen.tsx",
|
|
14
|
+
"./components": "./src/components/index.ts",
|
|
15
|
+
"./bridge": "./src/bridge/index.ts",
|
|
16
|
+
"./testing": "./src/testing/index.ts",
|
|
17
|
+
"./redirects": "./src/redirects/index.ts",
|
|
18
|
+
"./redirects/config": {
|
|
19
|
+
"types": "./src/redirects/config.d.mts",
|
|
20
|
+
"default": "./src/redirects/config.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./elements": "./src/elements/index.ts",
|
|
23
|
+
"./languages": "./src/languages/index.ts",
|
|
24
|
+
"./languages/config": {
|
|
25
|
+
"types": "./src/languages/config.d.mts",
|
|
26
|
+
"default": "./src/languages/config.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./legacy": "./src/legacy/index.ts",
|
|
29
|
+
"./legacy/bridge": "./src/legacy/bridge.tsx",
|
|
30
|
+
"./legacy/cli": "./src/legacy/cli.mjs",
|
|
31
|
+
"./legacy/draft": "./src/legacy/draft.tsx",
|
|
32
|
+
"./legacy/shape": "./src/legacy/shape.ts",
|
|
33
|
+
"./legacy/contract": "./src/legacy/contract.ts",
|
|
34
|
+
"./legacy/redirects": "./src/legacy/redirects.ts",
|
|
35
|
+
"./legacy/language-switcher": "./src/legacy/language-switcher.tsx",
|
|
36
|
+
"./features": "./src/features/client/index.ts",
|
|
37
|
+
"./features/styles.css": "./src/features/client/styles.css",
|
|
38
|
+
"./test-preview/server": "./src/test-preview/server.ts"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"!src/**/*.test.ts",
|
|
43
|
+
"!src/**/*.test.tsx",
|
|
44
|
+
"!src/**/*.test-d.ts",
|
|
45
|
+
"!src/**/*.test.mjs",
|
|
46
|
+
"!src/elements/fixtures",
|
|
47
|
+
"!src/elements/harness.ts",
|
|
48
|
+
"!src/site/fixtures",
|
|
49
|
+
"!src/test-preview/fixtures.ts",
|
|
50
|
+
"!src/features/consent/test-fixtures.ts"
|
|
51
|
+
],
|
|
52
|
+
"license": "UNLICENSED",
|
|
53
|
+
"homepage": "https://unitninety.com",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/UnitNinety/unitninety-run.git",
|
|
57
|
+
"directory": "packages/site-kit"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"zod": "4.4.3"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"react": ">=19",
|
|
67
|
+
"next": ">=16.3.8 <17"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"react": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"next": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"scripts": {
|
|
78
|
+
"typecheck": "tsc --noEmit",
|
|
79
|
+
"test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx' src/test-preview/protocol.test.mjs"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Fragment, useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
import { registry } from './registry'
|
|
6
|
+
import { needsEmbedReload, preserveBlockMoves } from './preserveBlockMoves'
|
|
7
|
+
import { PreviewCommitBoundary } from './PreviewCommitBoundary'
|
|
8
|
+
|
|
9
|
+
type Item = { id: string; path?: string; content: ReactNode }
|
|
10
|
+
type Move = { path: string; before: string | null }
|
|
11
|
+
|
|
12
|
+
const moved = (items: Item[], { path, before }: Move): Item[] => {
|
|
13
|
+
const item = items.find((entry) => entry.path === path)
|
|
14
|
+
if (!item || before === path) return items
|
|
15
|
+
const next = items.filter((entry) => entry !== item)
|
|
16
|
+
const at = before === null ? next.length : next.findIndex((entry) => entry.path === before)
|
|
17
|
+
if (at < 0) return items
|
|
18
|
+
next.splice(at, 0, item)
|
|
19
|
+
return next
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* React owns draft block order, including the optimistic order. Moving its DOM nodes outside
|
|
24
|
+
* React required undoing the drag before a refresh, visibly restoring the old order while the
|
|
25
|
+
* server answered. The next server render reconciles these same keyed children instead.
|
|
26
|
+
* Only draft-marked zones use this component; published pages keep their server-only renderer.
|
|
27
|
+
*/
|
|
28
|
+
export function DraftBlocks({ items }: { items: Item[] }) {
|
|
29
|
+
const host = useRef<HTMLDivElement | null>(null)
|
|
30
|
+
const reload = useRef(false)
|
|
31
|
+
// A stable ref keeps the adapter installed through React's mutation phase.
|
|
32
|
+
const attachHost = useCallback((node: HTMLDivElement | null) => {
|
|
33
|
+
host.current = node
|
|
34
|
+
return preserveBlockMoves(node)
|
|
35
|
+
}, [])
|
|
36
|
+
const [shown, setShown] = useState({ source: items, items, order: null as string[] | null })
|
|
37
|
+
// Keep the desired order, not a log to replay: the server response may already
|
|
38
|
+
// contain later moves, and applying a sequence twice is not generally idempotent.
|
|
39
|
+
const ranks = new Map(shown.order?.map((id, index) => [id, index]))
|
|
40
|
+
const known = items.filter((item) => ranks.has(item.id)).sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!)
|
|
41
|
+
let knownIndex = 0
|
|
42
|
+
const current = shown.source === items ? shown.items : shown.order === null ? items
|
|
43
|
+
: items.map((item) => ranks.has(item.id) ? known[knownIndex++]! : item)
|
|
44
|
+
if (shown.source !== items) setShown({ ...shown, source: items, items: current })
|
|
45
|
+
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
const handler = {
|
|
48
|
+
document,
|
|
49
|
+
needsReload: () => reload.current,
|
|
50
|
+
beforeRefresh: () => setShown((value) => ({ ...value, order: null })),
|
|
51
|
+
move: (path: string, before: string | null) => {
|
|
52
|
+
if (!current.some((item) => item.path === path)) return false
|
|
53
|
+
if (before !== null && !current.some((item) => item.path === before)) return false
|
|
54
|
+
if (host.current && needsEmbedReload(host.current)) {
|
|
55
|
+
reload.current = true
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
setShown((value) => {
|
|
59
|
+
const next = moved(value.items, { path, before })
|
|
60
|
+
return { ...value, items: next, order: next.map((item) => item.id) }
|
|
61
|
+
})
|
|
62
|
+
return true
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
registry.blockOrders.add(handler)
|
|
66
|
+
return () => { registry.blockOrders.delete(handler) }
|
|
67
|
+
}, [current])
|
|
68
|
+
|
|
69
|
+
return <PreviewCommitBoundary source={items}><div data-u90-draft-zone="" style={{ display: 'contents' }} ref={attachHost}>{current.map((item) => <Fragment key={item.id}>{item.content}</Fragment>)}</div></PreviewCommitBoundary>
|
|
70
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Fragment, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
|
|
4
|
+
import { PreviewCommitBoundary } from './PreviewCommitBoundary'
|
|
5
|
+
import { registry } from './registry'
|
|
6
|
+
|
|
7
|
+
export type EntryItem = { id: string; content: ReactNode }
|
|
8
|
+
export type EntryPicks = { order: string[]; exclude: string[]; pin: string[] }
|
|
9
|
+
|
|
10
|
+
function picked(items: EntryItem[], picks: EntryPicks, pins: readonly string[]): EntryItem[] {
|
|
11
|
+
const excluded = new Set([...picks.exclude, ...pins.filter(id => !picks.pin.includes(id))])
|
|
12
|
+
const ranks = new Map(picks.order.map((id, index) => [id, index]))
|
|
13
|
+
return items.filter(item => !excluded.has(item.id)).sort((a, b) =>
|
|
14
|
+
(ranks.get(a.id) ?? Infinity) - (ranks.get(b.id) ?? Infinity))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** React owns the cards so pending picks survive a slow server refresh without a DOM undo flash. */
|
|
18
|
+
export function DraftEntries({ path, items, pins, empty, ...props }: HTMLAttributes<HTMLDivElement> & { path: string; items: EntryItem[]; pins: string[]; empty?: ReactNode }) {
|
|
19
|
+
const host = useRef<HTMLDivElement>(null)
|
|
20
|
+
const [shown, setShown] = useState({ source: items, pins, items, picks: null as EntryPicks | null })
|
|
21
|
+
const current = shown.source === items ? shown.items : shown.picks === null ? items : picked(items, shown.picks, pins)
|
|
22
|
+
if (shown.source !== items) setShown({ ...shown, source: items, pins, items: current })
|
|
23
|
+
|
|
24
|
+
useLayoutEffect(() => {
|
|
25
|
+
const handler = {
|
|
26
|
+
document: host.current!.ownerDocument,
|
|
27
|
+
// Clear only the picks included when this refresh starts, retaining the visible cards
|
|
28
|
+
// until its response arrives. A subsequent edit remains active across that older response.
|
|
29
|
+
beforeRefresh: () => setShown(value => ({ ...value, picks: null })),
|
|
30
|
+
pick: (at: string, picks: EntryPicks) => {
|
|
31
|
+
if (at !== path) return false
|
|
32
|
+
setShown(value => ({ ...value, items: picked(value.source, picks, value.pins), picks }))
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
registry.entryLists.add(handler)
|
|
37
|
+
return () => { registry.entryLists.delete(handler) }
|
|
38
|
+
}, [path])
|
|
39
|
+
|
|
40
|
+
return <PreviewCommitBoundary source={items}><div {...props} data-u90-field={path} ref={host}>{current.length ? current.map(item => <Fragment key={item.id}>{item.content}</Fragment>) : empty}</div></PreviewCommitBoundary>
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode } from 'react'
|
|
4
|
+
import { registry } from './registry'
|
|
5
|
+
|
|
6
|
+
/** Rebase imperative field previews around the actual React mutation, not around
|
|
7
|
+
* the network request. A persistent Next layout need not render when its routed
|
|
8
|
+
* subtree changes, so each draft zone places this boundary around its server items. */
|
|
9
|
+
export class PreviewCommitBoundary extends Component<{ source: unknown; children: ReactNode }> {
|
|
10
|
+
getSnapshotBeforeUpdate(previous: Readonly<{ source: unknown; children: ReactNode }>) {
|
|
11
|
+
return previous.source === this.props.source ? null : registry.current?.prepareCommit() ?? null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
componentDidUpdate(_previous: unknown, _state: unknown, replay: null | (() => void)) {
|
|
15
|
+
replay?.()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render() { return this.props.children }
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { nextAdapters, type NextAdapters } from '../server/adapters'
|
|
2
|
+
import { createTestPreviewRuntime, type TestPreviewContext } from '../test-preview/runtime'
|
|
3
|
+
import { resolveBridgePreview } from '../test-preview/bridge'
|
|
4
|
+
import { Suspense, type ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { EditingProvider } from './hooks'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `draftMode()` is a request-time API: under Cache Components, reading it must happen behind a
|
|
10
|
+
* `Suspense` boundary rather than at a layout's top level (a top-level `await` there would hold
|
|
11
|
+
* `children` — the whole segment — behind the request on every render). `Gate` is that boundary's
|
|
12
|
+
* one child, so the read is pushed down into the smallest component that needs it, per Next's
|
|
13
|
+
* documented pattern for authentication/session reads under Cache Components.
|
|
14
|
+
*
|
|
15
|
+
* `EditingProvider` renders `children` itself (see `hooks.tsx`) — it has to, since a Context
|
|
16
|
+
* provider must be an ancestor of the components calling `useEditing()`/`useReveal()`, which
|
|
17
|
+
* means `children` sits inside this Suspense boundary too. In practice this costs nothing visible:
|
|
18
|
+
* `draftMode()` reads a cookie already on the request, no I/O, so `Gate` resolves in the same tick
|
|
19
|
+
* Ordinary visitors perform no I/O here. Test previews additionally verify their session with the
|
|
20
|
+
* source trust response; pass `u90.testPreview` to share the connection's bounded trust cache.
|
|
21
|
+
*/
|
|
22
|
+
type BridgeProps = { platformOrigin: string; children: ReactNode; draft: NextAdapters['draft']; testPreview?: () => Promise<TestPreviewContext | null> }
|
|
23
|
+
async function Gate({ platformOrigin, children, draft, testPreview }: BridgeProps) {
|
|
24
|
+
const runtime = createTestPreviewRuntime({ env: { PLATFORM_URL: process.env.PLATFORM_URL, PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY, SITE_URL: process.env.SITE_URL } })
|
|
25
|
+
const props = await resolveBridgePreview({ platformOrigin, draft, resolve: testPreview ? () => testPreview() : token => runtime.resolve(token) })
|
|
26
|
+
return <EditingProvider {...props}>{children}</EditingProvider>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps `{children}` in `app/layout.tsx` (`<U90Bridge>{children}</U90Bridge>`): outside draft, or
|
|
31
|
+
* with no `platformOrigin` configured, this renders `children` through the provider but through the async
|
|
32
|
+
* `Gate` and its `EditingProvider` — `useEditing()` still works everywhere, always returning
|
|
33
|
+
* `{ editing: false, selected: null }` in that case. Only when `draftMode()` is enabled and a
|
|
34
|
+
* `platformOrigin` is known does the client bridge (`BridgeClient`, lazily imported from
|
|
35
|
+
* `EditingProvider`) actually mount.
|
|
36
|
+
*
|
|
37
|
+
* `platformOrigin` defaults to `PLATFORM_URL`'s origin (empty when that env var is unset, which
|
|
38
|
+
* `Gate` reads as "never enable the bridge" — a draft page with no platform to talk to is just a
|
|
39
|
+
* normal render).
|
|
40
|
+
*/
|
|
41
|
+
export function U90Bridge({
|
|
42
|
+
children,
|
|
43
|
+
draft = nextAdapters.draft,
|
|
44
|
+
testPreview,
|
|
45
|
+
platformOrigin = process.env.PLATFORM_URL ? new URL(process.env.PLATFORM_URL).origin : '',
|
|
46
|
+
}: {
|
|
47
|
+
children: ReactNode
|
|
48
|
+
platformOrigin?: string
|
|
49
|
+
draft?: NextAdapters['draft']
|
|
50
|
+
/** Pass u90.testPreview to share its configured connection and injected adapters. */
|
|
51
|
+
testPreview?: () => Promise<TestPreviewContext | null>
|
|
52
|
+
}) {
|
|
53
|
+
return <Suspense fallback={null}><Gate platformOrigin={platformOrigin} draft={draft} testPreview={testPreview}>{children}</Gate></Suspense>
|
|
54
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which marked elements take a caret, and where the caret goes.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `bridge.tsx` for two reasons. It is the only part of the editing behaviour
|
|
5
|
+
* that is pure DOM — no React, no message protocol, no closure over the bridge's state — so
|
|
6
|
+
* it can be loaded and driven on its own, which is what `verify:caret` does: it runs these
|
|
7
|
+
* exact functions in a real browser against a real layout, rather than a transcription of
|
|
8
|
+
* them that could drift. And `bridge.tsx` was over a thousand lines.
|
|
9
|
+
*
|
|
10
|
+
* Ported from `legacy/caret.ts`, with one change throughout: v4 has no standalone `RICH_ATTR`
|
|
11
|
+
* boolean attribute or `SECTION_ATTR` — a field is `data-u90-field`, its edit kind (when it takes
|
|
12
|
+
* a caret at all) is `data-u90-edit="text" | "rich"`, and a block wrapper is `data-u90-block`
|
|
13
|
+
* rather than a digit-keyed `data-u90-section`. `FIELD_ATTR` is inlined below rather than
|
|
14
|
+
* imported from `./shape` (this v1 package has no `shape.ts`); every other line — the exported
|
|
15
|
+
* function bodies, the ordering, the comments — is unchanged from the legacy source.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The v4 spelling of the legacy `FIELD_ATTR` (`'data-u90-field'`), inlined rather than imported. */
|
|
19
|
+
const FIELD_ATTR = 'data-u90-field'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What an annotated element must not contain if it is to be typed into.
|
|
23
|
+
*
|
|
24
|
+
* A marked element holding an image or a video is a media field or a card, not words: its
|
|
25
|
+
* value is an upload id, and the caret has no business there. A nested control or link is
|
|
26
|
+
* excluded for a different reason — its own text is a separate field, or markup a plain
|
|
27
|
+
* string cannot round-trip, so editing the parent as one blob would flatten it.
|
|
28
|
+
*
|
|
29
|
+
* **`svg` is deliberately absent.** Every button on these sites is `<a>Contact Us<svg/></a>`
|
|
30
|
+
* — the label and its chevron — and excluding icons would have made the most obviously
|
|
31
|
+
* editable thing on the page the one thing you could not edit. `innerText` ignores an svg's
|
|
32
|
+
* contents, so the value is the words alone; and if a client deletes the icon while typing,
|
|
33
|
+
* the re-render on blur puts it straight back, because the icon was never theirs to store.
|
|
34
|
+
*
|
|
35
|
+
* The check is on *descendants* — an `<a>` that is itself the marked element is fine.
|
|
36
|
+
*/
|
|
37
|
+
export const NON_TEXT = 'img, picture, video, canvas, iframe, input, textarea, select, button, a'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The tags that are a picture rather than words — a deliberate *subset* of `NON_TEXT`.
|
|
41
|
+
*
|
|
42
|
+
* Two lists rather than one, and the difference is `a` and `button`. `NON_TEXT` answers "may
|
|
43
|
+
* this element *contain* something untypable", and a marked button label is both a control and
|
|
44
|
+
* a text leaf: with navigation off it takes a caret, which is how a client edits a button's
|
|
45
|
+
* words at all. Excluding every non-text tag from editability would therefore make every
|
|
46
|
+
* caption on every button uneditable in place — a far worse bug than the one this fixes.
|
|
47
|
+
*
|
|
48
|
+
* This list answers a different question: "*is* this element a picture". `NON_TEXT` could never
|
|
49
|
+
* answer it, because it is only ever asked about descendants — `querySelector`, `:has()` — and
|
|
50
|
+
* an `<img>` contains no `img`. So a marked image satisfied every editable-leaf test and
|
|
51
|
+
* offered an I-beam over a photograph.
|
|
52
|
+
*/
|
|
53
|
+
export const MEDIA = 'img, picture, video, canvas, iframe'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A marked element that is itself a picture.
|
|
57
|
+
*
|
|
58
|
+
* Keyed on the element's own tag rather than on what it contains, because the two spellings a
|
|
59
|
+
* site uses want different treatment: a marker *on* the image needs its affordance drawn inside
|
|
60
|
+
* itself, while a marker on a wrapper is normally the clipping frame and its outline is already
|
|
61
|
+
* visible outside it.
|
|
62
|
+
*/
|
|
63
|
+
export const MEDIA_SELECTOR = `[${FIELD_ATTR}]:is(${MEDIA})`
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether this annotated element is a leaf holding text, and so directly editable.
|
|
67
|
+
*
|
|
68
|
+
* Leaf means it contains no other annotated element. A container — a card marked as a
|
|
69
|
+
* whole, a section wrapping four marked children — would otherwise become one editable
|
|
70
|
+
* blob whose text belongs to several fields at once, and the first keystroke would write
|
|
71
|
+
* all of them into one.
|
|
72
|
+
*
|
|
73
|
+
* **A rich field is accepted here early**, ahead of the nested-marker and `NON_TEXT`
|
|
74
|
+
* clauses below, and that ordering is the point: a real document contains `<a>` and `<ul>`,
|
|
75
|
+
* and both of those clauses exist to stop an *ordinary* field being edited as one blob —
|
|
76
|
+
* applied to a rich field they would reject it for exactly the structure it is allowed to
|
|
77
|
+
* hold. The requirement was never "a rich field must not be edited in place"; a client
|
|
78
|
+
* choosing to type into their own heading is the same client choosing to type into their
|
|
79
|
+
* own paragraph. The requirement is "a rich field must never be read back as plain words",
|
|
80
|
+
* and that guarantee is the bridge's job — see `createBridge`'s `valueOf`, keyed on the same
|
|
81
|
+
* `data-u90-edit="rich"` attribute — not this function's.
|
|
82
|
+
*
|
|
83
|
+
* The `MEDIA` clause still runs unconditionally, ahead of the rich check: a marker on an
|
|
84
|
+
* image is a picture whatever else it carries, and the caret has no business there.
|
|
85
|
+
*
|
|
86
|
+
* v4 change: the legacy `element.hasAttribute(RICH_ATTR)` (a standalone boolean attribute)
|
|
87
|
+
* becomes `element.getAttribute('data-u90-edit') === 'rich'`, since v4 has one edit-kind
|
|
88
|
+
* attribute rather than a separate rich marker.
|
|
89
|
+
*/
|
|
90
|
+
export const isEditableLeaf = (element: HTMLElement): boolean => {
|
|
91
|
+
if (element.matches(MEDIA)) return false
|
|
92
|
+
if (element.getAttribute('data-u90-edit') === 'rich') return Boolean(element.textContent?.trim())
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
!element.querySelector(`[${FIELD_ATTR}]`) &&
|
|
96
|
+
!element.querySelector(NON_TEXT) &&
|
|
97
|
+
Boolean(element.textContent?.trim())
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The CSS spelling of `isEditableLeaf`, for the hover affordance.
|
|
103
|
+
*
|
|
104
|
+
* Built from the same constants so the I-beam and the behaviour cannot disagree about what
|
|
105
|
+
* is typable. `:is([data-u90-edit="rich"], …)` is the OR that mirrors the early return above: a
|
|
106
|
+
* rich field takes the I-beam without also having to clear the nested-marker and `NON_TEXT`
|
|
107
|
+
* clauses, which exist to protect an ordinary field and would otherwise disqualify a real
|
|
108
|
+
* document for the very links and lists it is allowed to contain.
|
|
109
|
+
*
|
|
110
|
+
* `:not(:is(${MEDIA}))` still runs unconditionally, outside that `:is()` — a marked image is
|
|
111
|
+
* a picture, rich or not, and the caret has no business there either.
|
|
112
|
+
*
|
|
113
|
+
* The one clause CSS still cannot express is "and it has some text in it" — an empty marked
|
|
114
|
+
* element therefore offers an I-beam and does nothing when clicked, which is the least
|
|
115
|
+
* interesting way for the two to fall out of step and beats making every site component
|
|
116
|
+
* carry a class.
|
|
117
|
+
*
|
|
118
|
+
* v4 change: `[${RICH_ATTR}]` (legacy's presence selector for the standalone rich attribute)
|
|
119
|
+
* becomes the value selector `[data-u90-edit="rich"]`.
|
|
120
|
+
*/
|
|
121
|
+
export const EDITABLE_LEAF_SELECTOR = `[${FIELD_ATTR}]:not(:is(${MEDIA})):is([data-u90-edit="rich"], :not(:has([${FIELD_ATTR}])):not(:has(${NON_TEXT})))`
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A collapsed range at a viewport point, across both spellings of the same API.
|
|
125
|
+
*
|
|
126
|
+
* `caretPositionFromPoint` is the standard one and what Firefox has always had;
|
|
127
|
+
* `caretRangeFromPoint` is WebKit's older name for it, still what Safari ships. Chromium
|
|
128
|
+
* answers to both. Neither is guaranteed, hence the null.
|
|
129
|
+
*/
|
|
130
|
+
export const caretFromPoint = (x: number, y: number): null | Range => {
|
|
131
|
+
const doc = document as Document & {
|
|
132
|
+
caretPositionFromPoint?: (x: number, y: number) => null | { offset: number; offsetNode: Node }
|
|
133
|
+
caretRangeFromPoint?: (x: number, y: number) => null | Range
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof doc.caretPositionFromPoint === 'function') {
|
|
137
|
+
const position = doc.caretPositionFromPoint(x, y)
|
|
138
|
+
if (!position) return null
|
|
139
|
+
const range = document.createRange()
|
|
140
|
+
range.setStart(position.offsetNode, position.offset)
|
|
141
|
+
range.collapse(true)
|
|
142
|
+
return range
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof doc.caretRangeFromPoint === 'function') {
|
|
146
|
+
const range = doc.caretRangeFromPoint(x, y)
|
|
147
|
+
range?.collapse(true)
|
|
148
|
+
return range
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where the caret should go for a click at this point — resolved, not yet applied.
|
|
156
|
+
*
|
|
157
|
+
* ## Call this *before* making the element editable
|
|
158
|
+
*
|
|
159
|
+
* The split into resolve-then-apply is the whole point of this pair, and it is not
|
|
160
|
+
* stylistic. Both inputs it reads are changed by `contenteditable`:
|
|
161
|
+
*
|
|
162
|
+
* - The point lookup. Measured on Chromium against `<a>Contact Us<svg/></a>` laid out as
|
|
163
|
+
* `inline-flex`: with the element inert, x-positions across the label resolve to text
|
|
164
|
+
* offsets 9, 12, 13 as you would expect. Set `contenteditable` first and every one of
|
|
165
|
+
* them comes back as offset 0 instead. The caret then lands at the front of the label
|
|
166
|
+
* however carefully the client aimed — which was the original bug wearing a new hat.
|
|
167
|
+
* - Any selection the browser has already made. `focus()` on a freshly editable element
|
|
168
|
+
* can collapse it, and a double-click's word selection is exactly that.
|
|
169
|
+
*
|
|
170
|
+
* So: resolve here, make the element editable, then `applyCaret`. Both call sites in
|
|
171
|
+
* `createBridge`'s `onClick` do that in that order, and it is why they hold a `Range` across
|
|
172
|
+
* the two.
|
|
173
|
+
*
|
|
174
|
+
* An existing run of selected text wins over the point. A press-and-drag across three words
|
|
175
|
+
* *is* a selection and the click ending it must not collapse it to a caret; the same clause
|
|
176
|
+
* is what keeps a double-click meaning "replace this word".
|
|
177
|
+
*/
|
|
178
|
+
export const caretTargetIn = (element: HTMLElement, x: number, y: number): Range => {
|
|
179
|
+
const selection = window.getSelection()
|
|
180
|
+
|
|
181
|
+
if (selection && !selection.isCollapsed && selection.rangeCount > 0) {
|
|
182
|
+
const existing = selection.getRangeAt(0)
|
|
183
|
+
// Cloned: the live range would be mutated by the focus that happens in between.
|
|
184
|
+
if (element.contains(existing.commonAncestorContainer)) return existing.cloneRange()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fromPoint = caretFromPoint(x, y)
|
|
188
|
+
return fromPoint && landsInText(element, fromPoint) ? fromPoint : endOf(element)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Apply a range resolved by `caretTargetIn`, after the element has been made editable. */
|
|
192
|
+
export const applyCaret = (range: Range): void => {
|
|
193
|
+
const selection = window.getSelection()
|
|
194
|
+
if (!selection) return
|
|
195
|
+
selection.removeAllRanges()
|
|
196
|
+
selection.addRange(range)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether a point lookup found somewhere a caret can actually sit.
|
|
201
|
+
*
|
|
202
|
+
* Three things have to hold, and the third is why this is a function rather than a
|
|
203
|
+
* `contains` call. Every button on these sites is `<a>Contact Us<svg/></a>`, and clicking
|
|
204
|
+
* the chevron resolves to a `path` node — which *is* inside the marked element, so a
|
|
205
|
+
* containment check alone accepts it and the caret is placed inside the icon. Typing then
|
|
206
|
+
* goes nowhere the client can see. The svg check is belt-and-braces on top of the text-node
|
|
207
|
+
* one: an `<svg><text>` would satisfy both otherwise.
|
|
208
|
+
*
|
|
209
|
+
* Anything rejected falls back to the end of the words, which for a button label clicked on
|
|
210
|
+
* its icon is where you wanted the caret anyway.
|
|
211
|
+
*/
|
|
212
|
+
const landsInText = (element: HTMLElement, range: Range): boolean => {
|
|
213
|
+
const container = range.startContainer
|
|
214
|
+
if (container.nodeType !== Node.TEXT_NODE) return false
|
|
215
|
+
if (!element.contains(container)) return false
|
|
216
|
+
return !container.parentElement?.closest('svg')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A collapsed range after the element's last visible character.
|
|
221
|
+
*
|
|
222
|
+
* **Not `selectNodeContents` + `collapse(false)`**, which is the obvious spelling and is
|
|
223
|
+
* wrong here. That produces a position anchored to the *element* just past its last child,
|
|
224
|
+
* and for `<a>Contact Us<svg/> </a>` the last child is collapsible whitespace after an icon
|
|
225
|
+
* — no visible caret position at all. Chromium normalises it, and measurably normalises it
|
|
226
|
+
* to the *front* of the element: typing after clicking the chevron inserted at the start of
|
|
227
|
+
* the label, which is the exact failure the fallback exists to avoid.
|
|
228
|
+
*
|
|
229
|
+
* So it anchors to the last text node that actually holds words, at the end of those words
|
|
230
|
+
* rather than the end of the node — trailing whitespace inside a text node is no better a
|
|
231
|
+
* caret position than trailing whitespace between them.
|
|
232
|
+
*/
|
|
233
|
+
const endOf = (element: HTMLElement): Range => {
|
|
234
|
+
const range = document.createRange()
|
|
235
|
+
const last = lastWordsIn(element)
|
|
236
|
+
|
|
237
|
+
if (!last) {
|
|
238
|
+
// No text at all. `isEditableLeaf` rules this out for the click path; a direct caller
|
|
239
|
+
// gets the old behaviour, which is the only thing available.
|
|
240
|
+
range.selectNodeContents(element)
|
|
241
|
+
range.collapse(false)
|
|
242
|
+
return range
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
range.setStart(last, (last.textContent ?? '').replace(/\s+$/, '').length)
|
|
246
|
+
range.collapse(true)
|
|
247
|
+
return range
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The last text node holding something other than whitespace, icons excluded. */
|
|
251
|
+
const lastWordsIn = (element: HTMLElement): null | Text => {
|
|
252
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
|
|
253
|
+
acceptNode: (node) =>
|
|
254
|
+
node.textContent?.trim() && !node.parentElement?.closest('svg')
|
|
255
|
+
? NodeFilter.FILTER_ACCEPT
|
|
256
|
+
: NodeFilter.FILTER_REJECT,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
let last: null | Text = null
|
|
260
|
+
while (walker.nextNode()) last = walker.currentNode as Text
|
|
261
|
+
return last
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Formatting tag → the mark name `u90:caret`'s `marks` reports it as (`protocol.ts`). Named to
|
|
266
|
+
* match `lexicalDom.ts`'s own `MARK_BITS` naming (`bold`, `italic`, `underline`, `strikethrough`,
|
|
267
|
+
* `code`) rather than the tags themselves, since both spellings a browser inserts (`strong`/`b`,
|
|
268
|
+
* `em`/`i`) collapse to the one mark the Canvas's toolbar highlights; `a` reports as `link`.
|
|
269
|
+
*/
|
|
270
|
+
const MARK_TAGS: Record<string, string> = {
|
|
271
|
+
a: 'link',
|
|
272
|
+
b: 'bold',
|
|
273
|
+
code: 'code',
|
|
274
|
+
em: 'italic',
|
|
275
|
+
i: 'italic',
|
|
276
|
+
s: 'strikethrough',
|
|
277
|
+
strong: 'bold',
|
|
278
|
+
u: 'underline',
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Block tag → the `u90:caret`'s `block` name (`protocol.ts`). */
|
|
282
|
+
const BLOCK_TAGS: Record<string, 'paragraph' | 'h2' | 'h3' | 'h4' | 'list' | 'quote'> = {
|
|
283
|
+
blockquote: 'quote',
|
|
284
|
+
h2: 'h2',
|
|
285
|
+
h3: 'h3',
|
|
286
|
+
h4: 'h4',
|
|
287
|
+
li: 'list',
|
|
288
|
+
p: 'paragraph',
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The formatting elements between `node` (the selection's anchor) and `root` (the field element
|
|
293
|
+
* `u90:format` applies to, exclusive) — the caret's `marks`. Walking ancestors rather than
|
|
294
|
+
* inspecting Lexical state: there is none here, only the live DOM a `contenteditable` selection
|
|
295
|
+
* sits in, so this reads the same tags `lexicalDom.ts`'s `MARK_BITS` inverts back out of it.
|
|
296
|
+
*/
|
|
297
|
+
export const activeMarks = (node: Node, root: HTMLElement): string[] => {
|
|
298
|
+
const marks = new Set<string>()
|
|
299
|
+
let el: Element | null = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
|
|
300
|
+
while (el && el !== root && root.contains(el)) {
|
|
301
|
+
const mark = MARK_TAGS[el.tagName.toLowerCase()]
|
|
302
|
+
if (mark) marks.add(mark)
|
|
303
|
+
el = el.parentElement
|
|
304
|
+
}
|
|
305
|
+
return [...marks]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The nearest block element between `node` and `root` (exclusive) — the caret's `block`. */
|
|
309
|
+
export const activeBlock = (node: Node, root: HTMLElement): 'paragraph' | 'h2' | 'h3' | 'h4' | 'list' | 'quote' | undefined => {
|
|
310
|
+
let el: Element | null = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
|
|
311
|
+
while (el && el !== root && root.contains(el)) {
|
|
312
|
+
const block = BLOCK_TAGS[el.tagName.toLowerCase()]
|
|
313
|
+
if (block) return block
|
|
314
|
+
el = el.parentElement
|
|
315
|
+
}
|
|
316
|
+
return undefined
|
|
317
|
+
}
|