@payload-onpage/react 0.0.0-stage → 0.1.0

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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/OnPageClient.d.ts +18 -0
  4. package/dist/OnPageClient.d.ts.map +1 -0
  5. package/dist/OnPageClient.js +51 -0
  6. package/dist/OnPageClient.js.map +1 -0
  7. package/dist/annotations.d.ts +55 -0
  8. package/dist/annotations.d.ts.map +1 -0
  9. package/dist/annotations.js +60 -0
  10. package/dist/annotations.js.map +1 -0
  11. package/dist/context.d.ts +7 -0
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/context.js +19 -0
  14. package/dist/context.js.map +1 -0
  15. package/dist/dom.d.ts +42 -0
  16. package/dist/dom.d.ts.map +1 -0
  17. package/dist/dom.js +95 -0
  18. package/dist/dom.js.map +1 -0
  19. package/dist/editor/FieldHost.d.ts +5 -0
  20. package/dist/editor/FieldHost.d.ts.map +1 -0
  21. package/dist/editor/FieldHost.js +36 -0
  22. package/dist/editor/FieldHost.js.map +1 -0
  23. package/dist/editor/Ghosts.d.ts +6 -0
  24. package/dist/editor/Ghosts.d.ts.map +1 -0
  25. package/dist/editor/Ghosts.js +37 -0
  26. package/dist/editor/Ghosts.js.map +1 -0
  27. package/dist/editor/OnPageEditor.d.ts +25 -0
  28. package/dist/editor/OnPageEditor.d.ts.map +1 -0
  29. package/dist/editor/OnPageEditor.js +211 -0
  30. package/dist/editor/OnPageEditor.js.map +1 -0
  31. package/dist/editor/useGhosts.d.ts +24 -0
  32. package/dist/editor/useGhosts.d.ts.map +1 -0
  33. package/dist/editor/useGhosts.js +34 -0
  34. package/dist/editor/useGhosts.js.map +1 -0
  35. package/dist/editor/usePageEvents.d.ts +3 -0
  36. package/dist/editor/usePageEvents.d.ts.map +1 -0
  37. package/dist/editor/usePageEvents.js +164 -0
  38. package/dist/editor/usePageEvents.js.map +1 -0
  39. package/dist/editor-kit.d.ts +19 -0
  40. package/dist/editor-kit.d.ts.map +1 -0
  41. package/dist/editor-kit.js +17 -0
  42. package/dist/editor-kit.js.map +1 -0
  43. package/dist/editors/CheckboxEditor.d.ts +3 -0
  44. package/dist/editors/CheckboxEditor.d.ts.map +1 -0
  45. package/dist/editors/CheckboxEditor.js +10 -0
  46. package/dist/editors/CheckboxEditor.js.map +1 -0
  47. package/dist/editors/ColorEditor.d.ts +4 -0
  48. package/dist/editors/ColorEditor.d.ts.map +1 -0
  49. package/dist/editors/ColorEditor.js +21 -0
  50. package/dist/editors/ColorEditor.js.map +1 -0
  51. package/dist/editors/DateEditor.d.ts +3 -0
  52. package/dist/editors/DateEditor.d.ts.map +1 -0
  53. package/dist/editors/DateEditor.js +28 -0
  54. package/dist/editors/DateEditor.js.map +1 -0
  55. package/dist/editors/InlineText.d.ts +4 -0
  56. package/dist/editors/InlineText.d.ts.map +1 -0
  57. package/dist/editors/InlineText.js +77 -0
  58. package/dist/editors/InlineText.js.map +1 -0
  59. package/dist/editors/NumberEditor.d.ts +4 -0
  60. package/dist/editors/NumberEditor.d.ts.map +1 -0
  61. package/dist/editors/NumberEditor.js +23 -0
  62. package/dist/editors/NumberEditor.js.map +1 -0
  63. package/dist/editors/RelationshipEditor.d.ts +3 -0
  64. package/dist/editors/RelationshipEditor.d.ts.map +1 -0
  65. package/dist/editors/RelationshipEditor.js +60 -0
  66. package/dist/editors/RelationshipEditor.js.map +1 -0
  67. package/dist/editors/SelectEditor.d.ts +4 -0
  68. package/dist/editors/SelectEditor.d.ts.map +1 -0
  69. package/dist/editors/SelectEditor.js +36 -0
  70. package/dist/editors/SelectEditor.js.map +1 -0
  71. package/dist/editors/UploadEditor.d.ts +3 -0
  72. package/dist/editors/UploadEditor.d.ts.map +1 -0
  73. package/dist/editors/UploadEditor.js +48 -0
  74. package/dist/editors/UploadEditor.js.map +1 -0
  75. package/dist/editors/index.d.ts +13 -0
  76. package/dist/editors/index.d.ts.map +1 -0
  77. package/dist/editors/index.js +23 -0
  78. package/dist/editors/index.js.map +1 -0
  79. package/dist/editors/useDebouncedSearch.d.ts +17 -0
  80. package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
  81. package/dist/editors/useDebouncedSearch.js +50 -0
  82. package/dist/editors/useDebouncedSearch.js.map +1 -0
  83. package/dist/hub.d.ts +14 -0
  84. package/dist/hub.d.ts.map +1 -0
  85. package/dist/hub.js +20 -0
  86. package/dist/hub.js.map +1 -0
  87. package/dist/index.d.ts +20 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/layout.d.ts +12 -0
  92. package/dist/layout.d.ts.map +1 -0
  93. package/dist/layout.js +83 -0
  94. package/dist/layout.js.map +1 -0
  95. package/dist/locators.d.ts +19 -0
  96. package/dist/locators.d.ts.map +1 -0
  97. package/dist/locators.js +215 -0
  98. package/dist/locators.js.map +1 -0
  99. package/dist/messages.d.ts +100 -0
  100. package/dist/messages.d.ts.map +1 -0
  101. package/dist/messages.js +112 -0
  102. package/dist/messages.js.map +1 -0
  103. package/dist/registry.d.ts +15 -0
  104. package/dist/registry.d.ts.map +1 -0
  105. package/dist/registry.js +29 -0
  106. package/dist/registry.js.map +1 -0
  107. package/dist/renderers.d.ts +7 -0
  108. package/dist/renderers.d.ts.map +1 -0
  109. package/dist/renderers.js +12 -0
  110. package/dist/renderers.js.map +1 -0
  111. package/dist/sanitize.d.ts +2 -0
  112. package/dist/sanitize.d.ts.map +1 -0
  113. package/dist/sanitize.js +49 -0
  114. package/dist/sanitize.js.map +1 -0
  115. package/dist/styles.d.ts +8 -0
  116. package/dist/styles.d.ts.map +1 -0
  117. package/dist/styles.js +122 -0
  118. package/dist/styles.js.map +1 -0
  119. package/dist/targets.d.ts +18 -0
  120. package/dist/targets.d.ts.map +1 -0
  121. package/dist/targets.js +86 -0
  122. package/dist/targets.js.map +1 -0
  123. package/dist/types.d.ts +131 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/ui/BlockPicker.d.ts +3 -0
  128. package/dist/ui/BlockPicker.d.ts.map +1 -0
  129. package/dist/ui/BlockPicker.js +27 -0
  130. package/dist/ui/BlockPicker.js.map +1 -0
  131. package/dist/ui/Dialog.d.ts +22 -0
  132. package/dist/ui/Dialog.d.ts.map +1 -0
  133. package/dist/ui/Dialog.js +83 -0
  134. package/dist/ui/Dialog.js.map +1 -0
  135. package/dist/ui/Dock.d.ts +7 -0
  136. package/dist/ui/Dock.d.ts.map +1 -0
  137. package/dist/ui/Dock.js +104 -0
  138. package/dist/ui/Dock.js.map +1 -0
  139. package/dist/ui/Overlay.d.ts +2 -0
  140. package/dist/ui/Overlay.d.ts.map +1 -0
  141. package/dist/ui/Overlay.js +150 -0
  142. package/dist/ui/Overlay.js.map +1 -0
  143. package/dist/ui/PostButton.d.ts +10 -0
  144. package/dist/ui/PostButton.d.ts.map +1 -0
  145. package/dist/ui/PostButton.js +7 -0
  146. package/dist/ui/PostButton.js.map +1 -0
  147. package/dist/ui/PreviewToolbar.d.ts +15 -0
  148. package/dist/ui/PreviewToolbar.d.ts.map +1 -0
  149. package/dist/ui/PreviewToolbar.js +45 -0
  150. package/dist/ui/PreviewToolbar.js.map +1 -0
  151. package/dist/ui/SettingsPanel.d.ts +11 -0
  152. package/dist/ui/SettingsPanel.d.ts.map +1 -0
  153. package/dist/ui/SettingsPanel.js +130 -0
  154. package/dist/ui/SettingsPanel.js.map +1 -0
  155. package/dist/useEvent.d.ts +7 -0
  156. package/dist/useEvent.d.ts.map +1 -0
  157. package/dist/useEvent.js +15 -0
  158. package/dist/useEvent.js.map +1 -0
  159. package/package.json +71 -4
  160. package/src/OnPageClient.tsx +67 -0
  161. package/src/annotations.ts +83 -0
  162. package/src/context.tsx +23 -0
  163. package/src/dom.ts +118 -0
  164. package/src/editor/FieldHost.tsx +66 -0
  165. package/src/editor/Ghosts.tsx +55 -0
  166. package/src/editor/OnPageEditor.tsx +315 -0
  167. package/src/editor/useGhosts.ts +64 -0
  168. package/src/editor/usePageEvents.ts +161 -0
  169. package/src/editor-kit.ts +27 -0
  170. package/src/editors/CheckboxEditor.tsx +31 -0
  171. package/src/editors/ColorEditor.tsx +64 -0
  172. package/src/editors/DateEditor.tsx +64 -0
  173. package/src/editors/InlineText.tsx +106 -0
  174. package/src/editors/NumberEditor.tsx +69 -0
  175. package/src/editors/RelationshipEditor.tsx +153 -0
  176. package/src/editors/SelectEditor.tsx +74 -0
  177. package/src/editors/UploadEditor.tsx +130 -0
  178. package/src/editors/index.ts +35 -0
  179. package/src/editors/useDebouncedSearch.ts +54 -0
  180. package/src/hub.ts +32 -0
  181. package/src/index.ts +55 -0
  182. package/src/layout.ts +83 -0
  183. package/src/locators.ts +210 -0
  184. package/src/messages.ts +120 -0
  185. package/src/registry.ts +43 -0
  186. package/src/renderers.ts +15 -0
  187. package/src/sanitize.ts +52 -0
  188. package/src/styles.ts +122 -0
  189. package/src/targets.ts +101 -0
  190. package/src/types.ts +132 -0
  191. package/src/ui/BlockPicker.tsx +49 -0
  192. package/src/ui/Dialog.tsx +159 -0
  193. package/src/ui/Dock.tsx +240 -0
  194. package/src/ui/Overlay.tsx +324 -0
  195. package/src/ui/PostButton.tsx +26 -0
  196. package/src/ui/PreviewToolbar.tsx +123 -0
  197. package/src/ui/SettingsPanel.tsx +279 -0
  198. package/src/useEvent.ts +16 -0
package/src/styles.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The editor's own styles, injected once. Everything is prefixed `po-` and set
3
+ * explicitly, so the host site's CSS (and its absence) doesn't matter.
4
+ */
5
+ export const PO_Z = 2147483000
6
+
7
+ export const PO_CSS = `
8
+ .po-layer{position:fixed;inset:0;pointer-events:none;z-index:${PO_Z};font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif;color:#0f172a}
9
+ .po-layer *{box-sizing:border-box}
10
+ .po-outline{position:fixed;border:2px solid #4f7cff;border-radius:4px;pointer-events:none;transition:all .06s ease-out}
11
+ .po-outline.is-block{border-style:dashed;border-color:#4f7cff99}
12
+ .po-outline.is-item{border-color:#22a06b}
13
+ .po-chip{position:fixed;transform:translateY(-100%);background:#3b63e0;color:#fff;font-size:11px;font-weight:600;padding:2px 6px;border-radius:4px 4px 0 0;white-space:nowrap;pointer-events:none}
14
+ .po-chip.is-item{background:#167a45}
15
+ .po-bar{position:fixed;display:flex;gap:2px;padding:3px;background:#0f172a;border-radius:8px;box-shadow:0 6px 24px #0f172a40;pointer-events:auto}
16
+ .po-bar.is-item{background:#14532d}
17
+ .po-btn{all:unset;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-width:28px;height:28px;padding:0 8px;border-radius:6px;color:#fff;font:600 12px/1 system-ui,-apple-system,sans-serif;cursor:pointer;white-space:nowrap}
18
+ .po-btn:hover{background:#ffffff1f}
19
+ .po-btn:focus-visible{outline:2px solid #93c5fd;outline-offset:1px}
20
+ .po-btn[disabled]{opacity:.35;cursor:default;background:none}
21
+ .po-btn.is-danger:hover{background:#ef444455}
22
+ .po-add{position:fixed;transform:translate(-50%,-50%);pointer-events:auto}
23
+ .po-add .po-btn{background:#3b63e0;box-shadow:0 2px 10px #4f7cff66;height:24px;font-size:11px}
24
+ .po-add .po-btn:hover{background:#2f5bd9}
25
+ .po-add.is-item .po-btn{background:#167a45;box-shadow:0 2px 10px #167a4555}
26
+ .po-panel{position:fixed;width:320px;max-height:min(70vh,560px);overflow:auto;background:#fff;color:#0f172a;border-radius:12px;box-shadow:0 20px 60px #0f172a40,0 0 0 1px #0f172a14;pointer-events:auto;padding:14px}
27
+ .po-panel .po-h3{margin:0 0 10px;font:700 13px/1.2 system-ui,sans-serif}
28
+ .po-panel .po-h4{margin:14px 0 6px;font:600 11px/1.2 system-ui,sans-serif;text-transform:uppercase;letter-spacing:.06em;color:#64748b}
29
+ .po-row{display:flex;flex-direction:column;gap:4px;margin:0 0 10px}
30
+ .po-row label{font-weight:600;font-size:12px}
31
+ .po-row small{color:#64748b;font-size:11px}
32
+ .po-input,.po-select{all:unset;box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;font:13px system-ui,sans-serif;color:#0f172a}
33
+ .po-select{appearance:auto;-webkit-appearance:auto}
34
+ .po-input:focus,.po-select:focus{border-color:#4f7cff;box-shadow:0 0 0 3px #4f7cff33}
35
+ .po-check{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600}
36
+ .po-seg{display:flex;flex-wrap:wrap;gap:4px}
37
+ .po-seg button{all:unset;padding:5px 9px;border:1px solid #cbd5e1;border-radius:7px;font:12px system-ui,sans-serif;cursor:pointer;background:#fff}
38
+ .po-seg button[aria-pressed="true"]{background:#0f172a;color:#fff;border-color:#0f172a}
39
+ .po-modal-bg{position:fixed;inset:0;background:#0f172a66;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:24px}
40
+ .po-modal{width:min(880px,100%);max-height:85vh;display:flex;flex-direction:column;background:#fff;border-radius:14px;box-shadow:0 30px 80px #0f172a55;overflow:hidden}
41
+ .po-modal header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid #e2e8f0}
42
+ .po-modal header .po-h3{margin:0;flex:1;font:700 14px system-ui,sans-serif}
43
+ .po-modal .po-body{padding:14px 16px;overflow:auto}
44
+ .po-modal .po-btn{color:#0f172a}
45
+ .po-modal .po-btn:hover{background:#f1f5f9}
46
+ .po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
47
+ .po-tile{all:unset;display:flex;flex-direction:column;gap:6px;cursor:pointer;border-radius:10px;padding:6px;border:2px solid transparent}
48
+ .po-tile:hover,.po-tile:focus-visible{border-color:#4f7cff;background:#f8fafc}
49
+ .po-tile img{width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:6px;background:#f1f5f9}
50
+ .po-tile span{font-size:11px;color:#475569;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
51
+ .po-blocks{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
52
+ .po-blocks button{all:unset;padding:14px;border:1px solid #e2e8f0;border-radius:10px;cursor:pointer;font:600 13px system-ui,sans-serif}
53
+ .po-blocks button:hover,.po-blocks button:focus-visible{border-color:#4f7cff;background:#f5f8ff}
54
+ .po-dock{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
55
+ .po-dock .po-status{padding:0 10px;font-size:12px;opacity:.85;white-space:nowrap}
56
+ .po-dock .po-status.is-bad{color:#fca5a5;opacity:1}
57
+ .po-dock .po-sep{width:1px;height:20px;background:#ffffff26;margin:0 4px}
58
+ .po-dock .po-primary{background:#3b63e0}
59
+ .po-dock .po-primary:hover{background:#2f5bd9}
60
+ .po-chip-locale{padding:2px 7px;border-radius:999px;background:#ffffff1f;font-size:11px;font-weight:700;text-transform:uppercase}
61
+ .po-notice{position:fixed;left:50%;bottom:72px;transform:translateX(-50%);max-width:min(560px,calc(100vw - 24px));padding:8px 12px;border-radius:10px;background:#fffbeb;color:#78350f;border:1px solid #fcd34d;font-size:12px;pointer-events:auto}
62
+ .po-doc-layer{position:absolute;top:0;left:0;width:0;height:0;overflow:visible;z-index:${PO_Z};pointer-events:none;font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif}
63
+ .po-inline{position:absolute;pointer-events:auto;outline:2px solid #4f7cff;outline-offset:2px;border-radius:2px;background:transparent;white-space:pre-wrap;word-break:break-word;caret-color:currentColor;cursor:text}
64
+ .po-inline:focus{outline-color:#4f7cff}
65
+ .po-rt{position:absolute;pointer-events:auto}
66
+ .po-rt-surface{outline:2px solid #4f7cff;outline-offset:6px;border-radius:2px;min-height:1.5em}
67
+ .po-rt-surface:focus{outline:2px solid #4f7cff}
68
+ .po-rt-toolbar{position:absolute;left:0;top:-48px;display:flex;gap:2px;padding:3px;background:#0f172a;border-radius:8px;box-shadow:0 6px 24px #0f172a40}
69
+ .po-rt-toolbar .po-btn[aria-pressed="true"]{background:#ffffff2e}
70
+ .po-pop{position:fixed;min-width:220px;max-width:340px;background:#fff;color:#0f172a;border-radius:10px;box-shadow:0 14px 40px #0f172a40,0 0 0 1px #0f172a14;pointer-events:auto;padding:10px}
71
+ .po-pop .po-row{margin-bottom:8px}
72
+ .po-pop-list{display:flex;flex-direction:column;gap:2px;max-height:260px;overflow:auto}
73
+ .po-pop-list button{all:unset;padding:7px 9px;border-radius:7px;cursor:pointer;font:13px system-ui,sans-serif}
74
+ .po-pop-list button:hover,.po-pop-list button:focus-visible,.po-pop-list button[aria-selected="true"]{background:#eef2ff}
75
+ .po-ghost{position:absolute;pointer-events:none;white-space:pre-wrap;word-break:break-word}
76
+ .po-docs{font-size:11px;padding:2px 7px;border-radius:999px;background:#ffffff1f}
77
+ .po-frame-bg{position:fixed;inset:0;background:#0f172acc;pointer-events:auto;display:flex;flex-direction:column;align-items:center;padding:56px 16px 16px}
78
+ .po-frame-bg iframe{background:#fff;border:0;border-radius:12px;box-shadow:0 30px 80px #0008;height:100%;max-width:100%}
79
+ .po-badge{padding:2px 8px;border-radius:999px;background:#f59e0b;color:#111;font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
80
+ .po-b{font-weight:700}.po-i{font-style:italic}.po-u{text-decoration:underline}
81
+ .po-opaque{display:inline-flex;align-items:center;gap:6px;margin:6px 0;padding:6px 10px;border:1px dashed #94a3b8;border-radius:8px;background:#f8fafc;color:#475569;font:12px system-ui,sans-serif;user-select:none}
82
+ html[data-onpage-editing] [data-onpage-field]{cursor:pointer}
83
+ [data-onpage-hidden="true"]{visibility:hidden!important}
84
+ .po-preview{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;padding:6px;background:#0f172a;color:#fff;border-radius:12px;box-shadow:0 12px 40px #0f172a55;pointer-events:auto;max-width:calc(100vw - 24px);flex-wrap:wrap}
85
+ .po-preview .po-status{padding:0 8px;font-size:12px;opacity:.85}
86
+ .po-rel-item{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;background:#f1f5f9;font-size:12px;margin:0 0 4px}
87
+ .po-rel-item button{all:unset;cursor:pointer;margin-left:auto;padding:0 4px;color:#64748b}
88
+
89
+ .po-dialog-head{display:flex;align-items:center;gap:8px;margin:0 0 10px}
90
+ .po-modal .po-dialog-head{margin:0;padding:14px 16px;border-bottom:1px solid #e2e8f0}
91
+ .po-dialog-head .po-h3{flex:1;margin:0;font:700 14px/1.2 system-ui,sans-serif}
92
+ .po-close{color:#0f172a;min-width:24px;height:24px}
93
+ .po-close:hover{background:#f1f5f9}
94
+ .po-bar-label{cursor:default;opacity:.8}
95
+ .po-muted{color:#64748b;margin:6px 0}
96
+ .po-error{color:#b91c1c;margin:6px 0}
97
+ .po-center{text-align:center;margin-top:12px}
98
+ .po-actions{display:flex;gap:6px}
99
+ .po-inline-row{flex-direction:row;align-items:center}
100
+ .po-search{width:240px}
101
+ .po-picker{cursor:pointer;display:flex;align-items:center}
102
+ .po-group{border:0;margin:0;padding:0}
103
+ .po-primary,.po-pop .po-primary,.po-panel .po-primary{background:#3b63e0;color:#fff}
104
+ .po-primary:hover{background:#2f5bd9}
105
+ .po-pop .po-btn,.po-panel .po-btn{color:#0f172a}
106
+ .po-pop .po-btn.po-primary,.po-panel .po-btn.po-primary{color:#fff}
107
+ .po-frame-close{all:unset;position:absolute;inset:0;cursor:pointer}
108
+ .po-frame-bg iframe{position:relative}
109
+ .po-input[aria-invalid="true"]{border-color:#dc2626}
110
+ html[data-onpage-editing] [data-onpage-focusable]:focus-visible{outline:2px solid #4f7cff;outline-offset:2px}
111
+ @media (prefers-reduced-motion:reduce){.po-outline{transition:none}}
112
+ `
113
+
114
+ let injected = false
115
+ export function injectStyles() {
116
+ if (injected || typeof document === 'undefined') return
117
+ injected = true
118
+ const el = document.createElement('style')
119
+ el.setAttribute('data-onpage-styles', '')
120
+ el.textContent = PO_CSS
121
+ document.head.appendChild(el)
122
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Keeps an index of the page's targets current: scans with the locator on
5
+ * mount and again whenever the page's DOM changes (a server refresh, a client
6
+ * re-render, lazy content), ignoring the editor's own UI. Rescans are
7
+ * debounced and run when the browser is idle.
8
+ */
9
+ import { targetKey, type Locator, type Target, type TargetKind } from '@payload-onpage/core'
10
+ import { useEffect, useState } from 'react'
11
+ import { ATTR } from './annotations.ts'
12
+ import { isOurs } from './dom.ts'
13
+ import type { TargetIndex } from './types.ts'
14
+
15
+ const FIELD_KINDS = new Set<TargetKind>(['text', 'textarea', 'richtext', 'media', 'field'])
16
+ export const isFieldTarget = (t: Target): boolean => FIELD_KINDS.has(t.kind)
17
+ export const isRowTarget = (t: Target): boolean => t.kind === 'item'
18
+
19
+ export function buildIndex(all: Target[]): TargetIndex {
20
+ const byEl = new Map<Element, Target[]>()
21
+ const byPath = new Map<string, Target[]>()
22
+ // Annotations first: they are precise, inferred targets are a fallback.
23
+ const sorted = [...all].sort(
24
+ (a, b) => Number(a.source !== 'annotation') - Number(b.source !== 'annotation'),
25
+ )
26
+ for (const t of sorted) {
27
+ byEl.set(t.el, [...(byEl.get(t.el) ?? []), t])
28
+ const k = targetKey(t.ref, t.path)
29
+ byPath.set(k, [...(byPath.get(k) ?? []), t])
30
+ }
31
+ return {
32
+ all: sorted,
33
+ at: (el) => byEl.get(el) ?? [],
34
+ find: (key, path, kinds) =>
35
+ (byPath.get(targetKey(key, path)) ?? []).find(
36
+ (t) => t.el.isConnected && (!kinds || kinds.includes(t.kind)),
37
+ ),
38
+ }
39
+ }
40
+
41
+ /** The innermost field target and all row targets (innermost first) at or above `el`. */
42
+ export function targetsAt(
43
+ index: TargetIndex,
44
+ el: Element | null,
45
+ ): { field?: Target; rows: Target[] } {
46
+ let field: Target | undefined
47
+ const rows: Target[] = []
48
+ for (let node = el; node; node = node.parentElement) {
49
+ for (const t of index.at(node)) {
50
+ if (!field && isFieldTarget(t)) field = t
51
+ if (isRowTarget(t)) rows.push(t)
52
+ }
53
+ }
54
+ return { field, rows }
55
+ }
56
+
57
+ const WATCHED = [ATTR.ref, ATTR.field, ATTR.row, ATTR.list, 'alt']
58
+ const idle = (fn: () => void): (() => void) => {
59
+ if (typeof requestIdleCallback === 'function') {
60
+ const h = requestIdleCallback(fn, { timeout: 300 })
61
+ return () => cancelIdleCallback(h)
62
+ }
63
+ const h = setTimeout(fn, 50)
64
+ return () => clearTimeout(h)
65
+ }
66
+
67
+ export function useTargets(locator: Locator, root: () => ParentNode | null): TargetIndex {
68
+ const [index, setIndex] = useState<TargetIndex>(() => buildIndex([]))
69
+ useEffect(() => {
70
+ const r = root()
71
+ if (!r) return
72
+ let debounce: ReturnType<typeof setTimeout> | undefined
73
+ let cancelIdle: (() => void) | undefined
74
+ const scan = () => setIndex(buildIndex(locator.scan(r)))
75
+ scan()
76
+ const observer = new MutationObserver((records) => {
77
+ if (
78
+ records.every((m) =>
79
+ isOurs(m.target instanceof Element ? m.target : m.target.parentElement),
80
+ )
81
+ )
82
+ return
83
+ clearTimeout(debounce)
84
+ cancelIdle?.()
85
+ debounce = setTimeout(() => (cancelIdle = idle(scan)), 120)
86
+ })
87
+ observer.observe(r, {
88
+ subtree: true,
89
+ childList: true,
90
+ characterData: true,
91
+ attributes: true,
92
+ attributeFilter: WATCHED,
93
+ })
94
+ return () => {
95
+ observer.disconnect()
96
+ clearTimeout(debounce)
97
+ cancelIdle?.()
98
+ }
99
+ }, [locator, root])
100
+ return index
101
+ }
package/src/types.ts ADDED
@@ -0,0 +1,132 @@
1
+ /** Public types of the editor UI. */
2
+ import type { ComponentType } from 'react'
3
+ import type {
4
+ DocRef,
5
+ EditorSession,
6
+ MediaLibrary,
7
+ Renderer,
8
+ SchemaField,
9
+ SessionSnapshot,
10
+ Target,
11
+ TargetKind,
12
+ } from '@payload-onpage/core'
13
+ import type { MessageKey, Translate } from './messages.ts'
14
+
15
+ export interface EditingState {
16
+ /** refKey of the document */
17
+ key: string
18
+ path: string
19
+ /** Editor id from the registry ("text", "richText", …). */
20
+ editor: string
21
+ point?: { x: number; y: number }
22
+ /** The element that was clicked (several elements may show one field). */
23
+ el?: Element
24
+ }
25
+
26
+ export type Panel =
27
+ | { kind: 'settings'; key: string; path: string; title: string }
28
+ | { kind: 'blocks'; key: string; listPath: string; index: number }
29
+ | null
30
+
31
+ export interface Selection {
32
+ key: string
33
+ path: string
34
+ }
35
+
36
+ /** Props every field editor receives. */
37
+ export interface FieldEditorProps {
38
+ docKey: string
39
+ path: string
40
+ field: SchemaField
41
+ /** The element showing the field (re-resolved after each refresh). */
42
+ element: () => Element | null
43
+ value: unknown
44
+ /** Commit a value (one undo step, autosaved). */
45
+ commit: (value: unknown) => void
46
+ /** Live preview while editing (no history entry, not saved). */
47
+ preview: (value: unknown) => void
48
+ /** Close the editor (after commit, or to cancel: transient previews are reverted). */
49
+ close: () => void
50
+ /** Leave a stand-in showing the new content until the page re-renders (refresh mode). */
51
+ ghost: (g: { html: string; className?: string; style?: Partial<CSSStyleDeclaration> }) => void
52
+ /** True when the page renders this document from editor state (instant); false = server refresh. */
53
+ live: boolean
54
+ point?: { x: number; y: number }
55
+ /** UI strings. */
56
+ t: Translate
57
+ }
58
+
59
+ /**
60
+ * A field editor (the FieldEditor port). `inline` editors replace the element
61
+ * in place (text, rich text); `popover` editors open beside it or as a dialog
62
+ * (select, date, relationship, image…). Register your own by id and refer to it
63
+ * from a field with `admin: { custom: { onpage: { editor: 'my-id' } } }`, or by
64
+ * field type ("json", "code"…) to replace or add a default.
65
+ */
66
+ export interface FieldEditorDef {
67
+ mode: 'inline' | 'popover'
68
+ component: ComponentType<FieldEditorProps>
69
+ }
70
+
71
+ export type EditorRegistry = Record<string, FieldEditorDef>
72
+
73
+ /** Navigation targets. `exit` and `mode` are endpoints the editor POSTs to. */
74
+ export interface Links {
75
+ exit: string
76
+ admin?: (ref: DocRef) => string
77
+ mode?: (mode: 'edit' | 'view') => string
78
+ }
79
+
80
+ export interface TargetIndex {
81
+ all: Target[]
82
+ /** Targets whose element is exactly `el`. */
83
+ at: (el: Element) => Target[]
84
+ find: (key: string, path: string, kinds?: TargetKind[]) => Target | undefined
85
+ }
86
+
87
+ /** What extensions and field editors can use from the editor (`useOnPage()`). */
88
+ export interface OnPageContextValue {
89
+ session: EditorSession
90
+ snap: SessionSnapshot
91
+ renderer: Renderer
92
+ media?: MediaLibrary
93
+ editors: EditorRegistry
94
+ targets: TargetIndex
95
+ links: Links
96
+ t: Translate
97
+ panel: Panel
98
+ setPanel: (p: Panel) => void
99
+ editing: EditingState | null
100
+ setEditing: (e: EditingState | null) => void
101
+ selected: Selection | null
102
+ setSelected: (s: Selection | null) => void
103
+ /** Documents shown on the current page (the session may hold others from earlier pages). */
104
+ docKeys: string[]
105
+ /** Is this document rendered from session state (client mode)? */
106
+ isLive: (key: string) => boolean
107
+ /** Client-mode documents register here (they don't need a server refresh). */
108
+ registerClientDoc: (key: string) => () => void
109
+ /** The page is re-rendering after a save (refresh mode). */
110
+ rendering: boolean
111
+ /** Open the right editor for a field (registry resolution); false if none applies. */
112
+ edit: (
113
+ key: string,
114
+ path: string,
115
+ opts?: { point?: { x: number; y: number }; kind?: TargetKind; anchor?: Element },
116
+ ) => boolean
117
+ /** Re-render documents after an operation outside autosave (publish, reload). */
118
+ rerender: (refs: DocRef[]) => Promise<void>
119
+ /** Show a short message in the dock. */
120
+ notify: (
121
+ key: MessageKey | (string & {}),
122
+ vars?: Record<string, string | number>,
123
+ bad?: boolean,
124
+ ) => void
125
+ addGhost: (g: {
126
+ key: string
127
+ path: string
128
+ html: string
129
+ className?: string
130
+ style?: Partial<CSSStyleDeclaration>
131
+ }) => void
132
+ }
@@ -0,0 +1,49 @@
1
+ 'use client'
2
+
3
+ import { fieldAtPath, insertItem, newBlockRow, updateArray } from '@payload-onpage/core'
4
+ import { useOnPage } from '../context.tsx'
5
+ import { Dialog } from './Dialog.tsx'
6
+
7
+ /** Choose a block type to insert at a position in a blocks field. */
8
+ export function BlockPicker() {
9
+ const { panel, setPanel, snap, session, setSelected, t } = useOnPage()
10
+ if (panel?.kind !== 'blocks') return null
11
+ const state = snap.docs[panel.key]
12
+ if (!state?.schema || !state.doc) return null
13
+ const { key, listPath, index } = panel
14
+ const field = fieldAtPath(state.schema.fields, state.doc, listPath)?.field
15
+ const blocks = field?.type === 'blocks' ? (field.blocks ?? []) : []
16
+
17
+ const choose = (slug: string) => {
18
+ const block = blocks.find((b) => b.slug === slug)
19
+ if (!block) return
20
+ session.apply(key, (d) =>
21
+ updateArray(d, listPath, (a) => insertItem(a, index, newBlockRow(block))),
22
+ )
23
+ setPanel(null)
24
+ setSelected({ key, path: `${listPath}.${index}` })
25
+ }
26
+
27
+ return (
28
+ <Dialog
29
+ title={t('blocks.title')}
30
+ onClose={() => setPanel(null)}
31
+ t={t}
32
+ width={640}
33
+ testId="onpage-block-picker"
34
+ >
35
+ <div className="po-blocks">
36
+ {blocks.map((b) => (
37
+ <button
38
+ key={b.slug}
39
+ type="button"
40
+ onClick={() => choose(b.slug)}
41
+ data-testid={`onpage-block-${b.slug}`}
42
+ >
43
+ {b.labels.singular}
44
+ </button>
45
+ ))}
46
+ </div>
47
+ </Dialog>
48
+ )
49
+ }
@@ -0,0 +1,159 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The one dialog primitive: labelled, focus moves in on open and back on
5
+ * close, Tab stays inside (modal), Escape closes. `variant="popover"` is
6
+ * anchored next to an element and closes on an outside click.
7
+ */
8
+ import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react'
9
+ import { focusableIn, popoverPosition } from '../dom.ts'
10
+ import { useLayoutTick } from '../layout.ts'
11
+ import type { Translate } from '../messages.ts'
12
+
13
+ export interface DialogProps {
14
+ title: string
15
+ onClose: () => void
16
+ children: ReactNode
17
+ t: Translate
18
+ /** modal: centered with a backdrop. panel: fixed side panel. popover: next to `anchor`. */
19
+ variant?: 'modal' | 'panel' | 'popover'
20
+ anchor?: () => Element | null
21
+ width?: number
22
+ testId?: string
23
+ /** Extra header content (search box, actions). */
24
+ actions?: ReactNode
25
+ }
26
+
27
+ export function Dialog({
28
+ title,
29
+ onClose,
30
+ children,
31
+ t,
32
+ variant = 'modal',
33
+ anchor,
34
+ width,
35
+ testId,
36
+ actions,
37
+ }: DialogProps) {
38
+ const box = useRef<HTMLDivElement>(null)
39
+ const titleId = useId()
40
+ const [height, setHeight] = useState(240)
41
+ useLayoutTick() // popovers follow their anchor
42
+
43
+ // Focus in on open, back on close.
44
+ useEffect(() => {
45
+ const previous = document.activeElement as HTMLElement | null
46
+ const el = box.current
47
+ if (el)
48
+ (
49
+ el.querySelector<HTMLElement>('[autofocus],[data-autofocus]') ??
50
+ focusableIn(el)[0] ??
51
+ el
52
+ ).focus({ preventScroll: true })
53
+ return () => previous?.focus?.({ preventScroll: true })
54
+ }, [])
55
+
56
+ // Popovers flip above their anchor when there's no room below: track their height.
57
+ useEffect(() => {
58
+ const el = box.current
59
+ if (variant !== 'popover' || !el) return
60
+ const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
61
+ ro.observe(el)
62
+ return () => ro.disconnect()
63
+ }, [variant])
64
+
65
+ useEffect(() => {
66
+ const onKey = (e: KeyboardEvent) => {
67
+ if (e.key === 'Escape') {
68
+ e.stopPropagation()
69
+ onClose()
70
+ return
71
+ }
72
+ if (e.key !== 'Tab' || variant !== 'modal' || !box.current) return
73
+ const items = focusableIn(box.current)
74
+ if (!items.length) return
75
+ const first = items[0]
76
+ const last = items[items.length - 1]
77
+ if (e.shiftKey && document.activeElement === first) {
78
+ e.preventDefault()
79
+ last.focus()
80
+ } else if (!e.shiftKey && document.activeElement === last) {
81
+ e.preventDefault()
82
+ first.focus()
83
+ }
84
+ }
85
+ const onDown = (e: PointerEvent) => {
86
+ if (variant === 'popover' && box.current && !box.current.contains(e.target as Node)) onClose()
87
+ }
88
+ document.addEventListener('keydown', onKey, true)
89
+ // Deferred, so the click that opened a popover doesn't close it.
90
+ const timer = setTimeout(() => document.addEventListener('pointerdown', onDown, true))
91
+ return () => {
92
+ clearTimeout(timer)
93
+ document.removeEventListener('keydown', onKey, true)
94
+ document.removeEventListener('pointerdown', onDown, true)
95
+ }
96
+ }, [onClose, variant])
97
+
98
+ const header = (
99
+ <header className="po-dialog-head">
100
+ <h2 className="po-h3" id={titleId}>
101
+ {title}
102
+ </h2>
103
+ {actions}
104
+ <button
105
+ type="button"
106
+ className="po-btn po-close"
107
+ onClick={onClose}
108
+ aria-label={t('common.close')}
109
+ >
110
+ ✕
111
+ </button>
112
+ </header>
113
+ )
114
+
115
+ if (variant === 'modal') {
116
+ return (
117
+ // Backdrop: a press outside the dialog closes it (Escape works from the keyboard).
118
+ <div
119
+ className="po-modal-bg"
120
+ onPointerDown={(e) => e.target === e.currentTarget && onClose()}
121
+ data-testid={testId}
122
+ >
123
+ <div
124
+ ref={box}
125
+ className="po-modal"
126
+ role="dialog"
127
+ aria-modal="true"
128
+ aria-labelledby={titleId}
129
+ tabIndex={-1}
130
+ style={width ? { width: `min(${width}px, 100%)` } : undefined}
131
+ >
132
+ {header}
133
+ <div className="po-body">{children}</div>
134
+ </div>
135
+ </div>
136
+ )
137
+ }
138
+ const style: CSSProperties =
139
+ variant === 'popover'
140
+ ? {
141
+ ...popoverPosition(anchor?.() ?? null, { width: width ?? 300, height }),
142
+ width: width ?? 300,
143
+ }
144
+ : { top: 72, right: 16 }
145
+ return (
146
+ <div
147
+ ref={box}
148
+ className={variant === 'popover' ? 'po-pop' : 'po-panel'}
149
+ role="dialog"
150
+ aria-labelledby={titleId}
151
+ tabIndex={-1}
152
+ style={style}
153
+ data-testid={testId}
154
+ >
155
+ {header}
156
+ {children}
157
+ </div>
158
+ )
159
+ }