@nuasite/cms 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 (269) hide show
  1. package/README.md +237 -0
  2. package/dist/src/build-processor.d.ts +20 -0
  3. package/dist/src/build-processor.d.ts.map +1 -0
  4. package/dist/src/collection-scanner.d.ts +6 -0
  5. package/dist/src/collection-scanner.d.ts.map +1 -0
  6. package/dist/src/component-registry.d.ts +63 -0
  7. package/dist/src/component-registry.d.ts.map +1 -0
  8. package/dist/src/config.d.ts +24 -0
  9. package/dist/src/config.d.ts.map +1 -0
  10. package/dist/src/dev-middleware.d.ts +20 -0
  11. package/dist/src/dev-middleware.d.ts.map +1 -0
  12. package/dist/src/editor/ai.d.ts +60 -0
  13. package/dist/src/editor/ai.d.ts.map +1 -0
  14. package/dist/src/editor/api.d.ts +140 -0
  15. package/dist/src/editor/api.d.ts.map +1 -0
  16. package/dist/src/editor/color-utils.d.ts +106 -0
  17. package/dist/src/editor/color-utils.d.ts.map +1 -0
  18. package/dist/src/editor/components/ai-chat.d.ts +11 -0
  19. package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
  20. package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
  21. package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
  22. package/dist/src/editor/components/attribute-editor.d.ts +5 -0
  23. package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
  24. package/dist/src/editor/components/block-editor.d.ts +12 -0
  25. package/dist/src/editor/components/block-editor.d.ts.map +1 -0
  26. package/dist/src/editor/components/collections-browser.d.ts +2 -0
  27. package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
  28. package/dist/src/editor/components/color-toolbar.d.ts +12 -0
  29. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
  30. package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
  31. package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
  32. package/dist/src/editor/components/create-page-modal.d.ts +2 -0
  33. package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
  34. package/dist/src/editor/components/editable-highlights.d.ts +9 -0
  35. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
  36. package/dist/src/editor/components/error-boundary.d.ts +32 -0
  37. package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
  38. package/dist/src/editor/components/fields.d.ts +75 -0
  39. package/dist/src/editor/components/fields.d.ts.map +1 -0
  40. package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
  41. package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
  42. package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
  43. package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
  44. package/dist/src/editor/components/image-overlay.d.ts +12 -0
  45. package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
  46. package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
  47. package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
  48. package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
  49. package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
  50. package/dist/src/editor/components/media-library.d.ts +2 -0
  51. package/dist/src/editor/components/media-library.d.ts.map +1 -0
  52. package/dist/src/editor/components/outline.d.ts +21 -0
  53. package/dist/src/editor/components/outline.d.ts.map +1 -0
  54. package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
  55. package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
  56. package/dist/src/editor/components/seo-editor.d.ts +2 -0
  57. package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
  58. package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
  59. package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
  60. package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
  61. package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
  62. package/dist/src/editor/components/toast/toast.d.ts +7 -0
  63. package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
  64. package/dist/src/editor/components/toast/types.d.ts +7 -0
  65. package/dist/src/editor/components/toast/types.d.ts.map +1 -0
  66. package/dist/src/editor/components/toolbar.d.ts +21 -0
  67. package/dist/src/editor/components/toolbar.d.ts.map +1 -0
  68. package/dist/src/editor/config.d.ts +4 -0
  69. package/dist/src/editor/config.d.ts.map +1 -0
  70. package/dist/src/editor/constants.d.ts +101 -0
  71. package/dist/src/editor/constants.d.ts.map +1 -0
  72. package/dist/src/editor/context.d.ts +14 -0
  73. package/dist/src/editor/context.d.ts.map +1 -0
  74. package/dist/src/editor/dom.d.ts +77 -0
  75. package/dist/src/editor/dom.d.ts.map +1 -0
  76. package/dist/src/editor/editor.d.ts +64 -0
  77. package/dist/src/editor/editor.d.ts.map +1 -0
  78. package/dist/src/editor/history.d.ts +20 -0
  79. package/dist/src/editor/history.d.ts.map +1 -0
  80. package/dist/src/editor/hooks/index.d.ts +14 -0
  81. package/dist/src/editor/hooks/index.d.ts.map +1 -0
  82. package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
  83. package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
  84. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
  85. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
  86. package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
  87. package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
  88. package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
  89. package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
  90. package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
  91. package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
  92. package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
  93. package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
  94. package/dist/src/editor/hooks/utils.d.ts +32 -0
  95. package/dist/src/editor/hooks/utils.d.ts.map +1 -0
  96. package/dist/src/editor/index.d.ts +12 -0
  97. package/dist/src/editor/index.d.ts.map +1 -0
  98. package/dist/src/editor/lib/cn.d.ts +3 -0
  99. package/dist/src/editor/lib/cn.d.ts.map +1 -0
  100. package/dist/src/editor/manifest.d.ts +19 -0
  101. package/dist/src/editor/manifest.d.ts.map +1 -0
  102. package/dist/src/editor/markdown-api.d.ts +36 -0
  103. package/dist/src/editor/markdown-api.d.ts.map +1 -0
  104. package/dist/src/editor/signals.d.ts +242 -0
  105. package/dist/src/editor/signals.d.ts.map +1 -0
  106. package/dist/src/editor/storage.d.ts +27 -0
  107. package/dist/src/editor/storage.d.ts.map +1 -0
  108. package/dist/src/editor/text-styling.d.ts +350 -0
  109. package/dist/src/editor/text-styling.d.ts.map +1 -0
  110. package/dist/src/editor/themes.d.ts +38 -0
  111. package/dist/src/editor/themes.d.ts.map +1 -0
  112. package/dist/src/editor/types.d.ts +454 -0
  113. package/dist/src/editor/types.d.ts.map +1 -0
  114. package/dist/src/error-collector.d.ts +56 -0
  115. package/dist/src/error-collector.d.ts.map +1 -0
  116. package/dist/src/handlers/component-ops.d.ts +34 -0
  117. package/dist/src/handlers/component-ops.d.ts.map +1 -0
  118. package/dist/src/handlers/markdown-ops.d.ts +41 -0
  119. package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
  120. package/dist/src/handlers/request-utils.d.ts +20 -0
  121. package/dist/src/handlers/request-utils.d.ts.map +1 -0
  122. package/dist/src/handlers/source-writer.d.ts +51 -0
  123. package/dist/src/handlers/source-writer.d.ts.map +1 -0
  124. package/dist/src/html-processor.d.ts +63 -0
  125. package/dist/src/html-processor.d.ts.map +1 -0
  126. package/dist/src/index.d.ts +41 -0
  127. package/dist/src/index.d.ts.map +1 -0
  128. package/dist/src/manifest-writer.d.ts +111 -0
  129. package/dist/src/manifest-writer.d.ts.map +1 -0
  130. package/dist/src/media/contember.d.ts +15 -0
  131. package/dist/src/media/contember.d.ts.map +1 -0
  132. package/dist/src/media/local.d.ts +9 -0
  133. package/dist/src/media/local.d.ts.map +1 -0
  134. package/dist/src/media/s3.d.ts +12 -0
  135. package/dist/src/media/s3.d.ts.map +1 -0
  136. package/dist/src/media/types.d.ts +40 -0
  137. package/dist/src/media/types.d.ts.map +1 -0
  138. package/dist/src/preview-generator.d.ts +19 -0
  139. package/dist/src/preview-generator.d.ts.map +1 -0
  140. package/dist/src/seo-processor.d.ts +23 -0
  141. package/dist/src/seo-processor.d.ts.map +1 -0
  142. package/dist/src/source-finder/ast-extractors.d.ts +35 -0
  143. package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
  144. package/dist/src/source-finder/ast-parser.d.ts +16 -0
  145. package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
  146. package/dist/src/source-finder/cache.d.ts +18 -0
  147. package/dist/src/source-finder/cache.d.ts.map +1 -0
  148. package/dist/src/source-finder/collection-finder.d.ts +29 -0
  149. package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
  150. package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
  151. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
  152. package/dist/src/source-finder/element-finder.d.ts +42 -0
  153. package/dist/src/source-finder/element-finder.d.ts.map +1 -0
  154. package/dist/src/source-finder/image-finder.d.ts +24 -0
  155. package/dist/src/source-finder/image-finder.d.ts.map +1 -0
  156. package/dist/src/source-finder/index.d.ts +9 -0
  157. package/dist/src/source-finder/index.d.ts.map +1 -0
  158. package/dist/src/source-finder/search-index.d.ts +27 -0
  159. package/dist/src/source-finder/search-index.d.ts.map +1 -0
  160. package/dist/src/source-finder/snippet-utils.d.ts +90 -0
  161. package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
  162. package/dist/src/source-finder/source-lookup.d.ts +16 -0
  163. package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
  164. package/dist/src/source-finder/types.d.ts +167 -0
  165. package/dist/src/source-finder/types.d.ts.map +1 -0
  166. package/dist/src/source-finder/variable-extraction.d.ts +37 -0
  167. package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
  168. package/dist/src/tailwind-colors.d.ts +54 -0
  169. package/dist/src/tailwind-colors.d.ts.map +1 -0
  170. package/dist/src/tsconfig.tsbuildinfo +1 -0
  171. package/dist/src/types.d.ts +367 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/utils.d.ts +61 -0
  174. package/dist/src/utils.d.ts.map +1 -0
  175. package/dist/src/vite-plugin.d.ts +14 -0
  176. package/dist/src/vite-plugin.d.ts.map +1 -0
  177. package/dist/types/tsconfig.tsbuildinfo +1 -0
  178. package/package.json +80 -0
  179. package/src/build-processor.ts +784 -0
  180. package/src/collection-scanner.ts +304 -0
  181. package/src/component-registry.ts +393 -0
  182. package/src/config.ts +74 -0
  183. package/src/dev-middleware.ts +525 -0
  184. package/src/dist/src/tsconfig.tsbuildinfo +1 -0
  185. package/src/editor/ai.ts +185 -0
  186. package/src/editor/api.ts +513 -0
  187. package/src/editor/color-utils.ts +556 -0
  188. package/src/editor/components/ai-chat.tsx +632 -0
  189. package/src/editor/components/ai-tooltip.tsx +179 -0
  190. package/src/editor/components/attribute-editor.tsx +596 -0
  191. package/src/editor/components/block-editor.tsx +546 -0
  192. package/src/editor/components/collections-browser.tsx +248 -0
  193. package/src/editor/components/color-toolbar.tsx +314 -0
  194. package/src/editor/components/confirm-dialog.tsx +69 -0
  195. package/src/editor/components/create-page-modal.tsx +163 -0
  196. package/src/editor/components/editable-highlights.tsx +260 -0
  197. package/src/editor/components/error-boundary.tsx +87 -0
  198. package/src/editor/components/fields.tsx +387 -0
  199. package/src/editor/components/frontmatter-fields.tsx +469 -0
  200. package/src/editor/components/highlight-overlay.ts +229 -0
  201. package/src/editor/components/image-overlay.tsx +230 -0
  202. package/src/editor/components/markdown-editor-overlay.tsx +505 -0
  203. package/src/editor/components/markdown-inline-editor.tsx +780 -0
  204. package/src/editor/components/media-library.tsx +297 -0
  205. package/src/editor/components/outline.tsx +402 -0
  206. package/src/editor/components/redirect-countdown.tsx +45 -0
  207. package/src/editor/components/seo-editor.tsx +498 -0
  208. package/src/editor/components/text-style-toolbar.tsx +362 -0
  209. package/src/editor/components/toast/toast-container.tsx +15 -0
  210. package/src/editor/components/toast/toast.tsx +49 -0
  211. package/src/editor/components/toast/types.ts +7 -0
  212. package/src/editor/components/toolbar.tsx +366 -0
  213. package/src/editor/config.ts +12 -0
  214. package/src/editor/constants.ts +106 -0
  215. package/src/editor/context.tsx +38 -0
  216. package/src/editor/dom.ts +357 -0
  217. package/src/editor/editor.ts +1510 -0
  218. package/src/editor/env.d.ts +4 -0
  219. package/src/editor/history.ts +355 -0
  220. package/src/editor/hooks/index.ts +19 -0
  221. package/src/editor/hooks/useAIHandlers.ts +345 -0
  222. package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
  223. package/src/editor/hooks/useElementDetection.ts +284 -0
  224. package/src/editor/hooks/useImageHoverDetection.ts +102 -0
  225. package/src/editor/hooks/useTextSelection.ts +187 -0
  226. package/src/editor/hooks/useTooltipState.ts +126 -0
  227. package/src/editor/hooks/utils.ts +101 -0
  228. package/src/editor/index.tsx +481 -0
  229. package/src/editor/lib/cn.ts +4 -0
  230. package/src/editor/manifest.ts +25 -0
  231. package/src/editor/markdown-api.ts +209 -0
  232. package/src/editor/signals.ts +1351 -0
  233. package/src/editor/storage.ts +266 -0
  234. package/src/editor/styles.css +465 -0
  235. package/src/editor/text-styling.ts +773 -0
  236. package/src/editor/themes.ts +210 -0
  237. package/src/editor/types.ts +591 -0
  238. package/src/error-collector.ts +106 -0
  239. package/src/handlers/component-ops.ts +463 -0
  240. package/src/handlers/markdown-ops.ts +202 -0
  241. package/src/handlers/request-utils.ts +151 -0
  242. package/src/handlers/source-writer.ts +649 -0
  243. package/src/html-processor.ts +1108 -0
  244. package/src/index.ts +284 -0
  245. package/src/manifest-writer.ts +371 -0
  246. package/src/media/contember.ts +84 -0
  247. package/src/media/local.ts +114 -0
  248. package/src/media/s3.ts +133 -0
  249. package/src/media/types.ts +33 -0
  250. package/src/preview-generator.ts +293 -0
  251. package/src/seo-processor.ts +567 -0
  252. package/src/source-finder/ast-extractors.ts +185 -0
  253. package/src/source-finder/ast-parser.ts +150 -0
  254. package/src/source-finder/cache.ts +76 -0
  255. package/src/source-finder/collection-finder.ts +335 -0
  256. package/src/source-finder/cross-file-tracker.ts +741 -0
  257. package/src/source-finder/element-finder.ts +387 -0
  258. package/src/source-finder/image-finder.ts +283 -0
  259. package/src/source-finder/index.ts +37 -0
  260. package/src/source-finder/search-index.ts +525 -0
  261. package/src/source-finder/snippet-utils.ts +668 -0
  262. package/src/source-finder/source-lookup.ts +200 -0
  263. package/src/source-finder/types.ts +210 -0
  264. package/src/source-finder/variable-extraction.ts +406 -0
  265. package/src/tailwind-colors.ts +874 -0
  266. package/src/tsconfig.json +25 -0
  267. package/src/types.ts +406 -0
  268. package/src/utils.ts +186 -0
  269. package/src/vite-plugin.ts +42 -0
@@ -0,0 +1,596 @@
1
+ import { useCallback, useEffect, useMemo } from 'preact/hooks'
2
+ import { Z_INDEX } from '../constants'
3
+ import * as signals from '../signals'
4
+ import { saveAttributeEditsToStorage } from '../storage'
5
+ import type { Attribute } from '../types'
6
+ import { ComboBoxField, FieldLabel, ImageField, NumberField, SelectField, TextField, ToggleField } from './fields'
7
+
8
+ // ============================================================================
9
+ // Attribute Field Configuration
10
+ // ============================================================================
11
+
12
+ type FieldType = 'text' | 'select' | 'toggle' | 'number' | 'media-src' | 'href'
13
+
14
+ interface FieldConfig {
15
+ type: FieldType
16
+ label: string
17
+ options?: Array<{ value: string; label: string }>
18
+ placeholder?: string
19
+ min?: number
20
+ max?: number
21
+ }
22
+
23
+ /** Maps attribute names to their smart field type and options */
24
+ const ATTRIBUTE_FIELD_CONFIG: Record<string, FieldConfig> = {
25
+ // Link / navigation
26
+ href: { type: 'href', label: 'URL (href)', placeholder: 'https://example.com or /page' },
27
+ target: {
28
+ type: 'select',
29
+ label: 'Target',
30
+ options: [
31
+ { value: '_self', label: 'Same window (_self)' },
32
+ { value: '_blank', label: 'New window (_blank)' },
33
+ { value: '_parent', label: 'Parent frame (_parent)' },
34
+ { value: '_top', label: 'Top frame (_top)' },
35
+ ],
36
+ },
37
+ rel: { type: 'text', label: 'Rel', placeholder: 'noopener noreferrer' },
38
+ title: { type: 'text', label: 'Title', placeholder: 'Title text' },
39
+
40
+ // Button
41
+ type: {
42
+ type: 'select',
43
+ label: 'Type',
44
+ options: [
45
+ { value: 'button', label: 'Button' },
46
+ { value: 'submit', label: 'Submit' },
47
+ { value: 'reset', label: 'Reset' },
48
+ { value: 'text', label: 'Text' },
49
+ { value: 'email', label: 'Email' },
50
+ { value: 'password', label: 'Password' },
51
+ { value: 'number', label: 'Number' },
52
+ { value: 'tel', label: 'Phone' },
53
+ { value: 'url', label: 'URL' },
54
+ { value: 'search', label: 'Search' },
55
+ { value: 'date', label: 'Date' },
56
+ { value: 'time', label: 'Time' },
57
+ { value: 'datetime-local', label: 'Datetime' },
58
+ { value: 'checkbox', label: 'Checkbox' },
59
+ { value: 'radio', label: 'Radio' },
60
+ { value: 'file', label: 'File' },
61
+ { value: 'hidden', label: 'Hidden' },
62
+ ],
63
+ },
64
+
65
+ // Form
66
+ action: { type: 'text', label: 'Action', placeholder: '/submit' },
67
+ method: {
68
+ type: 'select',
69
+ label: 'Method',
70
+ options: [
71
+ { value: 'get', label: 'GET' },
72
+ { value: 'post', label: 'POST' },
73
+ ],
74
+ },
75
+ enctype: {
76
+ type: 'select',
77
+ label: 'Encoding Type',
78
+ options: [
79
+ { value: 'application/x-www-form-urlencoded', label: 'URL Encoded (default)' },
80
+ { value: 'multipart/form-data', label: 'Multipart (for files)' },
81
+ { value: 'text/plain', label: 'Plain Text' },
82
+ ],
83
+ },
84
+
85
+ // Input
86
+ name: { type: 'text', label: 'Name', placeholder: 'field-name' },
87
+ placeholder: { type: 'text', label: 'Placeholder', placeholder: 'Enter value...' },
88
+ pattern: { type: 'text', label: 'Pattern', placeholder: '[A-Za-z]+' },
89
+ value: { type: 'text', label: 'Value', placeholder: 'Default value' },
90
+ autocomplete: {
91
+ type: 'select',
92
+ label: 'Autocomplete',
93
+ options: [
94
+ { value: 'on', label: 'On' },
95
+ { value: 'off', label: 'Off' },
96
+ { value: 'name', label: 'Name' },
97
+ { value: 'email', label: 'Email' },
98
+ { value: 'username', label: 'Username' },
99
+ { value: 'current-password', label: 'Current Password' },
100
+ { value: 'new-password', label: 'New Password' },
101
+ { value: 'tel', label: 'Phone' },
102
+ { value: 'address-line1', label: 'Address Line 1' },
103
+ { value: 'address-line2', label: 'Address Line 2' },
104
+ { value: 'city', label: 'City' },
105
+ { value: 'postal-code', label: 'Postal Code' },
106
+ { value: 'country', label: 'Country' },
107
+ ],
108
+ },
109
+ inputmode: {
110
+ type: 'select',
111
+ label: 'Input Mode',
112
+ options: [
113
+ { value: 'text', label: 'Text' },
114
+ { value: 'decimal', label: 'Decimal' },
115
+ { value: 'numeric', label: 'Numeric' },
116
+ { value: 'tel', label: 'Phone' },
117
+ { value: 'email', label: 'Email' },
118
+ { value: 'url', label: 'URL' },
119
+ { value: 'search', label: 'Search' },
120
+ { value: 'none', label: 'None' },
121
+ ],
122
+ },
123
+
124
+ // Media
125
+ src: { type: 'media-src', label: 'Source (src)', placeholder: '/media/file.mp4' },
126
+ poster: { type: 'text', label: 'Poster', placeholder: '/media/poster.jpg' },
127
+ loading: {
128
+ type: 'select',
129
+ label: 'Loading',
130
+ options: [
131
+ { value: 'eager', label: 'Eager' },
132
+ { value: 'lazy', label: 'Lazy' },
133
+ ],
134
+ },
135
+ preload: {
136
+ type: 'select',
137
+ label: 'Preload',
138
+ options: [
139
+ { value: 'auto', label: 'Auto' },
140
+ { value: 'metadata', label: 'Metadata only' },
141
+ { value: 'none', label: 'None' },
142
+ ],
143
+ },
144
+
145
+ // Iframe
146
+ allow: { type: 'text', label: 'Allow', placeholder: 'camera; microphone' },
147
+ sandbox: { type: 'text', label: 'Sandbox', placeholder: 'allow-scripts allow-same-origin' },
148
+
149
+ // Textarea
150
+ wrap: {
151
+ type: 'select',
152
+ label: 'Wrap',
153
+ options: [
154
+ { value: 'soft', label: 'Soft' },
155
+ { value: 'hard', label: 'Hard' },
156
+ { value: 'off', label: 'Off' },
157
+ ],
158
+ },
159
+
160
+ // Toggle (boolean) attributes
161
+ disabled: { type: 'toggle', label: 'Disabled' },
162
+ required: { type: 'toggle', label: 'Required' },
163
+ readonly: { type: 'toggle', label: 'Read Only' },
164
+ multiple: { type: 'toggle', label: 'Multiple' },
165
+ controls: { type: 'toggle', label: 'Controls' },
166
+ autoplay: { type: 'toggle', label: 'Autoplay' },
167
+ muted: { type: 'toggle', label: 'Muted' },
168
+ loop: { type: 'toggle', label: 'Loop' },
169
+ novalidate: { type: 'toggle', label: 'Disable Validation' },
170
+ download: { type: 'toggle', label: 'Download' },
171
+ 'aria-hidden': { type: 'toggle', label: 'Hidden (ARIA)' },
172
+ 'aria-expanded': { type: 'toggle', label: 'Expanded (ARIA)' },
173
+ 'aria-disabled': { type: 'toggle', label: 'Disabled (ARIA)' },
174
+
175
+ // Number attributes
176
+ rows: { type: 'number', label: 'Rows', min: 1 },
177
+ cols: { type: 'number', label: 'Cols', min: 1 },
178
+ min: { type: 'text', label: 'Min', placeholder: '0' },
179
+ max: { type: 'text', label: 'Max', placeholder: '100' },
180
+ minlength: { type: 'number', label: 'Min Length', min: 0 },
181
+ maxlength: { type: 'number', label: 'Max Length', min: 0 },
182
+ size: { type: 'number', label: 'Size', min: 1 },
183
+ width: { type: 'text', label: 'Width', placeholder: '100%' },
184
+ height: { type: 'text', label: 'Height', placeholder: '400' },
185
+ step: { type: 'text', label: 'Step', placeholder: '1' },
186
+
187
+ // ARIA
188
+ role: {
189
+ type: 'select',
190
+ label: 'Role',
191
+ options: [
192
+ { value: 'button', label: 'Button' },
193
+ { value: 'link', label: 'Link' },
194
+ { value: 'tab', label: 'Tab' },
195
+ { value: 'tabpanel', label: 'Tab Panel' },
196
+ { value: 'dialog', label: 'Dialog' },
197
+ { value: 'navigation', label: 'Navigation' },
198
+ { value: 'banner', label: 'Banner' },
199
+ { value: 'main', label: 'Main' },
200
+ { value: 'complementary', label: 'Complementary' },
201
+ { value: 'contentinfo', label: 'Content Info' },
202
+ { value: 'search', label: 'Search' },
203
+ { value: 'form', label: 'Form' },
204
+ { value: 'region', label: 'Region' },
205
+ { value: 'alert', label: 'Alert' },
206
+ { value: 'alertdialog', label: 'Alert Dialog' },
207
+ { value: 'menu', label: 'Menu' },
208
+ { value: 'menuitem', label: 'Menu Item' },
209
+ { value: 'listbox', label: 'Listbox' },
210
+ { value: 'option', label: 'Option' },
211
+ { value: 'tree', label: 'Tree' },
212
+ { value: 'treeitem', label: 'Tree Item' },
213
+ { value: 'grid', label: 'Grid' },
214
+ { value: 'row', label: 'Row' },
215
+ { value: 'cell', label: 'Cell' },
216
+ ],
217
+ },
218
+ 'aria-label': { type: 'text', label: 'Label (ARIA)', placeholder: 'Accessible label' },
219
+ 'aria-labelledby': { type: 'text', label: 'Labelled By (ARIA)', placeholder: 'element-id' },
220
+ 'aria-describedby': { type: 'text', label: 'Described By (ARIA)', placeholder: 'description-id' },
221
+ 'aria-live': {
222
+ type: 'select',
223
+ label: 'Live Region (ARIA)',
224
+ options: [
225
+ { value: 'polite', label: 'Polite' },
226
+ { value: 'assertive', label: 'Assertive' },
227
+ { value: 'off', label: 'Off' },
228
+ ],
229
+ },
230
+
231
+ // Form element attributes
232
+ form: { type: 'text', label: 'Form ID', placeholder: 'form-id' },
233
+ formaction: { type: 'text', label: 'Form Action', placeholder: '/submit' },
234
+ formmethod: {
235
+ type: 'select',
236
+ label: 'Form Method',
237
+ options: [
238
+ { value: 'get', label: 'GET' },
239
+ { value: 'post', label: 'POST' },
240
+ ],
241
+ },
242
+ }
243
+
244
+ /** Get field config for an attribute, falling back to text field */
245
+ function getFieldConfig(attrName: string): FieldConfig {
246
+ return ATTRIBUTE_FIELD_CONFIG[attrName] || {
247
+ type: 'text' as const,
248
+ label: attrName,
249
+ placeholder: `${attrName} value`,
250
+ }
251
+ }
252
+
253
+ /** Helper to compare attribute values (handles undefined, strings, booleans, numbers) */
254
+ function isValueDirty(newVal: any, origVal: any): boolean {
255
+ const normalizeValue = (v: any) => {
256
+ if (v === null || v === '' || v === undefined) return undefined
257
+ return v
258
+ }
259
+ return normalizeValue(newVal) !== normalizeValue(origVal)
260
+ }
261
+
262
+ // ============================================================================
263
+ // Dynamic Attribute Field Renderer
264
+ // ============================================================================
265
+
266
+ interface AttributeFieldProps {
267
+ attrName: string
268
+ currentAttr: Attribute | undefined
269
+ originalAttr: Attribute | undefined
270
+ pages: Array<{ url: string; title?: string }>
271
+ onUpdate: (value: string) => void
272
+ onReset: () => void
273
+ onOpenMediaLibrary: () => void
274
+ }
275
+
276
+ function AttributeField({ attrName, currentAttr, originalAttr, pages, onUpdate, onReset, onOpenMediaLibrary }: AttributeFieldProps) {
277
+ const config = getFieldConfig(attrName)
278
+ const currentValue = currentAttr?.value ?? ''
279
+ const originalValue = originalAttr?.value ?? ''
280
+ const isDirty = isValueDirty(currentValue, originalValue)
281
+ const handleReset = isDirty ? onReset : undefined
282
+
283
+ switch (config.type) {
284
+ case 'select':
285
+ return (
286
+ <SelectField
287
+ label={config.label}
288
+ value={currentValue || undefined}
289
+ options={config.options || []}
290
+ onChange={(v) => onUpdate(v)}
291
+ isDirty={isDirty}
292
+ onReset={handleReset}
293
+ />
294
+ )
295
+
296
+ case 'toggle': {
297
+ // For toggle, an attribute with empty string value means "present" (true)
298
+ const isPresent = currentAttr !== undefined && currentValue !== 'false'
299
+ return (
300
+ <ToggleField
301
+ label={config.label}
302
+ value={isPresent}
303
+ onChange={(v) => onUpdate(v ? 'true' : 'false')}
304
+ isDirty={isDirty}
305
+ onReset={handleReset}
306
+ />
307
+ )
308
+ }
309
+
310
+ case 'number': {
311
+ const numValue = currentValue ? Number(currentValue) : undefined
312
+ return (
313
+ <NumberField
314
+ label={config.label}
315
+ value={numValue}
316
+ placeholder={config.placeholder}
317
+ min={config.min}
318
+ max={config.max}
319
+ onChange={(v) => onUpdate(v === undefined ? '' : String(v))}
320
+ isDirty={isDirty}
321
+ onReset={handleReset}
322
+ />
323
+ )
324
+ }
325
+
326
+ case 'media-src':
327
+ return (
328
+ <ImageField
329
+ label={config.label}
330
+ value={currentValue || undefined}
331
+ placeholder={config.placeholder}
332
+ onChange={(v) => onUpdate(v)}
333
+ onBrowse={onOpenMediaLibrary}
334
+ isDirty={isDirty}
335
+ onReset={handleReset}
336
+ />
337
+ )
338
+
339
+ case 'href': {
340
+ const pageOptions = pages.map(p => ({
341
+ value: p.url,
342
+ label: p.title || p.url,
343
+ description: p.title ? p.url : undefined,
344
+ }))
345
+ return (
346
+ <ComboBoxField
347
+ label={config.label}
348
+ value={currentValue || undefined}
349
+ placeholder={config.placeholder}
350
+ options={pageOptions}
351
+ onChange={(v) => onUpdate(v)}
352
+ isDirty={isDirty}
353
+ onReset={handleReset}
354
+ />
355
+ )
356
+ }
357
+
358
+ default:
359
+ return (
360
+ <TextField
361
+ label={config.label}
362
+ value={currentValue || undefined}
363
+ placeholder={config.placeholder}
364
+ onChange={(v) => onUpdate(v)}
365
+ isDirty={isDirty}
366
+ onReset={handleReset}
367
+ />
368
+ )
369
+ }
370
+ }
371
+
372
+ // ============================================================================
373
+ // Main Component
374
+ // ============================================================================
375
+
376
+ export interface AttributeEditorProps {
377
+ onClose?: () => void
378
+ }
379
+
380
+ export function AttributeEditor({ onClose }: AttributeEditorProps) {
381
+ const visible = signals.isAttributeEditorOpen.value
382
+ const targetElementId = signals.attributeEditorTargetId.value
383
+ const config = signals.config.value
384
+ const manifest = signals.manifest.value
385
+
386
+ // Force re-render when pendingAttributeChanges updates by reading the whole map
387
+ const pendingAttributeChangesMap = signals.pendingAttributeChanges.value
388
+
389
+ // Get the pending attribute change and manifest entry
390
+ const pendingChange = targetElementId
391
+ ? pendingAttributeChangesMap.get(targetElementId)
392
+ : null
393
+ const entry = targetElementId ? manifest.entries[targetElementId] : null
394
+
395
+ // Get page URLs for link suggestions
396
+ const pages = useMemo(() => {
397
+ return (manifest.pages || []).map(page => ({
398
+ url: page.pathname,
399
+ title: page.title,
400
+ }))
401
+ }, [manifest.pages])
402
+
403
+ // Count dirty attributes
404
+ let dirtyCount = 0
405
+ if (pendingChange) {
406
+ const { originalAttributes, newAttributes } = pendingChange
407
+ const allKeys = new Set([...Object.keys(originalAttributes), ...Object.keys(newAttributes)])
408
+ for (const key of allKeys) {
409
+ if (isValueDirty(newAttributes[key]?.value, originalAttributes[key]?.value)) {
410
+ dirtyCount++
411
+ }
412
+ }
413
+ }
414
+
415
+ // Handle close
416
+ const handleClose = useCallback(() => {
417
+ signals.closeAttributeEditor()
418
+ onClose?.()
419
+ }, [onClose])
420
+
421
+ // Close on click outside
422
+ useEffect(() => {
423
+ if (!visible) return
424
+
425
+ const handleClickOutside = (e: MouseEvent) => {
426
+ const target = e.target as HTMLElement
427
+ if (target.closest('[data-cms-ui]')) return
428
+ handleClose()
429
+ }
430
+
431
+ const timeout = setTimeout(() => {
432
+ document.addEventListener('click', handleClickOutside)
433
+ }, 100)
434
+
435
+ return () => {
436
+ clearTimeout(timeout)
437
+ document.removeEventListener('click', handleClickOutside)
438
+ }
439
+ }, [visible, handleClose])
440
+
441
+ // Handle attribute update
442
+ const handleAttrUpdate = useCallback((attrName: string, newValue: string) => {
443
+ if (!targetElementId) return
444
+
445
+ signals.updatePendingAttributeChange(targetElementId, (currentChange) => {
446
+ const newAttributes = { ...currentChange.newAttributes }
447
+ const existingAttr = newAttributes[attrName] || currentChange.originalAttributes[attrName]
448
+ newAttributes[attrName] = {
449
+ ...(existingAttr || {}),
450
+ value: newValue,
451
+ }
452
+
453
+ // Check if dirty
454
+ let isDirty = false
455
+ const allKeys = new Set([...Object.keys(currentChange.originalAttributes), ...Object.keys(newAttributes)])
456
+ for (const key of allKeys) {
457
+ if (isValueDirty(newAttributes[key]?.value, currentChange.originalAttributes[key]?.value)) {
458
+ isDirty = true
459
+ break
460
+ }
461
+ }
462
+
463
+ // Apply to DOM element
464
+ if (currentChange.element) {
465
+ const booleanAttrs = new Set([
466
+ 'disabled',
467
+ 'required',
468
+ 'readonly',
469
+ 'multiple',
470
+ 'controls',
471
+ 'autoplay',
472
+ 'muted',
473
+ 'loop',
474
+ 'novalidate',
475
+ 'download',
476
+ 'aria-hidden',
477
+ 'aria-expanded',
478
+ 'aria-disabled',
479
+ ])
480
+
481
+ if (booleanAttrs.has(attrName)) {
482
+ if (newValue === 'true' || newValue === '') {
483
+ currentChange.element.setAttribute(attrName, '')
484
+ } else {
485
+ currentChange.element.removeAttribute(attrName)
486
+ }
487
+ } else if (newValue === '' || newValue === undefined) {
488
+ currentChange.element.removeAttribute(attrName)
489
+ } else {
490
+ currentChange.element.setAttribute(attrName, newValue)
491
+ }
492
+ }
493
+
494
+ return {
495
+ ...currentChange,
496
+ newAttributes,
497
+ isDirty,
498
+ }
499
+ })
500
+
501
+ // Save to storage for persistence
502
+ saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
503
+ }, [targetElementId])
504
+
505
+ // Open media library for src selection
506
+ const handleOpenMediaLibrary = useCallback(() => {
507
+ signals.openMediaLibraryWithCallback((url: string) => {
508
+ if (!targetElementId) return
509
+ handleAttrUpdate('src', url)
510
+ })
511
+ }, [targetElementId, handleAttrUpdate])
512
+
513
+ if (!visible || !targetElementId || !pendingChange) {
514
+ return null
515
+ }
516
+
517
+ const { originalAttributes, newAttributes } = pendingChange
518
+
519
+ // Get sorted attribute names
520
+ const attrNames = Object.keys(newAttributes)
521
+ const hasAnyAttributes = attrNames.length > 0
522
+
523
+ return (
524
+ <div
525
+ data-cms-ui
526
+ onMouseDown={(e) => e.stopPropagation()}
527
+ onClick={(e) => e.stopPropagation()}
528
+ class="right-8 top-8 bottom-8 fixed text-xs w-80"
529
+ style={{
530
+ zIndex: Z_INDEX.MODAL,
531
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
532
+ }}
533
+ >
534
+ <div class="bg-cms-dark border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-cms-lg flex flex-col h-full overflow-hidden">
535
+ {/* Header */}
536
+ <div class="flex items-center justify-between p-4 border-b border-white/10">
537
+ <div class="flex items-center gap-2">
538
+ <span class="font-medium text-white">Element Attributes</span>
539
+ {dirtyCount > 0 && (
540
+ <span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
541
+ {dirtyCount}
542
+ </span>
543
+ )}
544
+ </div>
545
+ <button
546
+ type="button"
547
+ onClick={handleClose}
548
+ class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
549
+ data-cms-ui
550
+ >
551
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
552
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
553
+ </svg>
554
+ </button>
555
+ </div>
556
+
557
+ {/* Content */}
558
+ <div class="flex-1 overflow-y-auto p-4">
559
+ {!hasAnyAttributes
560
+ ? (
561
+ <div class="flex flex-col items-center justify-center h-48 text-white/50">
562
+ <svg class="w-12 h-12 mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
563
+ <path
564
+ stroke-linecap="round"
565
+ stroke-linejoin="round"
566
+ stroke-width="1.5"
567
+ d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
568
+ />
569
+ </svg>
570
+ <p class="text-sm">No editable attributes found.</p>
571
+ <p class="text-xs text-white/40 mt-1">This element has no tracked attributes.</p>
572
+ </div>
573
+ )
574
+ : (
575
+ <div class="space-y-3">
576
+ {attrNames.map((attrName, index) => (
577
+ <div key={attrName}>
578
+ {index > 0 && <div class="h-px bg-white/5 mb-3" />}
579
+ <AttributeField
580
+ attrName={attrName}
581
+ currentAttr={newAttributes[attrName]}
582
+ originalAttr={originalAttributes[attrName]}
583
+ pages={pages}
584
+ onUpdate={(value) => handleAttrUpdate(attrName, value)}
585
+ onReset={() => handleAttrUpdate(attrName, originalAttributes[attrName]?.value ?? '')}
586
+ onOpenMediaLibrary={handleOpenMediaLibrary}
587
+ />
588
+ </div>
589
+ ))}
590
+ </div>
591
+ )}
592
+ </div>
593
+ </div>
594
+ </div>
595
+ )
596
+ }