portal-mojo 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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,254 @@
1
+ import {
2
+ combinedFilters, cropDataFromBox, operationOutputSize, transformOutputSize,
3
+ type FilterState, type ImageEditorOperation, type ImageSize, type TransformState,
4
+ } from './math';
5
+
6
+ export type ImageEditorSource = string | Blob;
7
+
8
+ export interface PixelSurface extends ImageSize {
9
+ data: Uint8ClampedArray;
10
+ }
11
+
12
+ export interface DecodedImage {
13
+ pixels: PixelSurface;
14
+ filename: string;
15
+ dispose: () => void;
16
+ }
17
+
18
+ export class ImageEditorError extends Error {
19
+ constructor(message = 'The image could not be processed.') {
20
+ super(message);
21
+ this.name = 'ImageEditorError';
22
+ }
23
+ }
24
+
25
+ const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)));
26
+
27
+ function copySurface(surface: PixelSurface): PixelSurface {
28
+ return { width: surface.width, height: surface.height, data: new Uint8ClampedArray(surface.data) };
29
+ }
30
+
31
+ function pixelAt(surface: PixelSurface, x: number, y: number, channel: number): number {
32
+ if (x < 0 || y < 0 || x >= surface.width || y >= surface.height) return 0;
33
+ return surface.data[(y * surface.width + x) * 4 + channel]!;
34
+ }
35
+
36
+ function transformPixels(surface: PixelSurface, transform: TransformState): PixelSurface {
37
+ const output = transformOutputSize(surface, transform);
38
+ const data = new Uint8ClampedArray(output.width * output.height * 4);
39
+ const radians = transform.rotation * Math.PI / 180;
40
+ const cos = Math.cos(radians);
41
+ const sin = Math.sin(radians);
42
+ for (let y = 0; y < output.height; y += 1) {
43
+ for (let x = 0; x < output.width; x += 1) {
44
+ const dx = x + 0.5 - output.width / 2 - transform.translateX;
45
+ const dy = y + 0.5 - output.height / 2 - transform.translateY;
46
+ const sx = Math.floor((dx * cos + dy * sin) / transform.scale + surface.width / 2);
47
+ const sy = Math.floor((-dx * sin + dy * cos) / transform.scale + surface.height / 2);
48
+ const target = (y * output.width + x) * 4;
49
+ for (let channel = 0; channel < 4; channel += 1) data[target + channel] = pixelAt(surface, sx, sy, channel);
50
+ }
51
+ }
52
+ return { ...output, data };
53
+ }
54
+
55
+ function cropPixels(surface: PixelSurface, rect: { x: number; y: number; width: number; height: number }, output: ImageSize | null): PixelSurface {
56
+ const crop = cropDataFromBox(rect, surface);
57
+ const width = Math.max(1, Math.floor(output?.width ?? crop.width));
58
+ const height = Math.max(1, Math.floor(output?.height ?? crop.height));
59
+ const data = new Uint8ClampedArray(width * height * 4);
60
+ for (let y = 0; y < height; y += 1) {
61
+ for (let x = 0; x < width; x += 1) {
62
+ const sx = Math.floor(crop.x + (x + 0.5) * crop.width / width);
63
+ const sy = Math.floor(crop.y + (y + 0.5) * crop.height / height);
64
+ const target = (y * width + x) * 4;
65
+ for (let channel = 0; channel < 4; channel += 1) data[target + channel] = pixelAt(surface, sx, sy, channel);
66
+ }
67
+ }
68
+ return { width, height, data };
69
+ }
70
+
71
+ function boxBlur(surface: PixelSurface, radius: number): PixelSurface {
72
+ const size = Math.max(0, Math.round(radius));
73
+ if (!size) return surface;
74
+ const output = copySurface(surface);
75
+ for (let y = 0; y < surface.height; y += 1) {
76
+ for (let x = 0; x < surface.width; x += 1) {
77
+ const totals = [0, 0, 0, 0];
78
+ let count = 0;
79
+ for (let by = Math.max(0, y - size); by <= Math.min(surface.height - 1, y + size); by += 1) {
80
+ for (let bx = Math.max(0, x - size); bx <= Math.min(surface.width - 1, x + size); bx += 1) {
81
+ for (let channel = 0; channel < 4; channel += 1) totals[channel] += pixelAt(surface, bx, by, channel);
82
+ count += 1;
83
+ }
84
+ }
85
+ const target = (y * surface.width + x) * 4;
86
+ for (let channel = 0; channel < 4; channel += 1) output.data[target + channel] = clampByte(totals[channel]! / count);
87
+ }
88
+ }
89
+ return output;
90
+ }
91
+
92
+ /** Deterministic, alpha-preserving implementation of the legacy CSS-filter order. */
93
+ export function filterPixels(surface: PixelSurface, filters: FilterState): PixelSurface {
94
+ let output = copySurface(surface);
95
+ const brightness = filters.brightness / 100;
96
+ const contrast = filters.contrast / 100;
97
+ const saturation = filters.saturation / 100;
98
+ const hue = filters.hue * Math.PI / 180;
99
+ const cos = Math.cos(hue);
100
+ const sin = Math.sin(hue);
101
+
102
+ for (let index = 0; index < output.data.length; index += 4) {
103
+ let r = output.data[index]! * brightness;
104
+ let g = output.data[index + 1]! * brightness;
105
+ let b = output.data[index + 2]! * brightness;
106
+ r = (r - 128) * contrast + 128;
107
+ g = (g - 128) * contrast + 128;
108
+ b = (b - 128) * contrast + 128;
109
+
110
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
111
+ r = lum + (r - lum) * saturation;
112
+ g = lum + (g - lum) * saturation;
113
+ b = lum + (b - lum) * saturation;
114
+
115
+ const hr = (0.213 + cos * 0.787 - sin * 0.213) * r + (0.715 - cos * 0.715 - sin * 0.715) * g + (0.072 - cos * 0.072 + sin * 0.928) * b;
116
+ const hg = (0.213 - cos * 0.213 + sin * 0.143) * r + (0.715 + cos * 0.285 + sin * 0.140) * g + (0.072 - cos * 0.072 - sin * 0.283) * b;
117
+ const hb = (0.213 - cos * 0.213 - sin * 0.787) * r + (0.715 - cos * 0.715 + sin * 0.715) * g + (0.072 + cos * 0.928 + sin * 0.072) * b;
118
+ r = hr; g = hg; b = hb;
119
+
120
+ output.data[index] = clampByte(r);
121
+ output.data[index + 1] = clampByte(g);
122
+ output.data[index + 2] = clampByte(b);
123
+ // Alpha is deliberately untouched by colour operations.
124
+ }
125
+ output = boxBlur(output, filters.blur);
126
+ const grayscale = filters.grayscale / 100;
127
+ const sepia = filters.sepia / 100;
128
+ for (let index = 0; index < output.data.length; index += 4) {
129
+ let r = output.data[index]!;
130
+ let g = output.data[index + 1]!;
131
+ let b = output.data[index + 2]!;
132
+ const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
133
+ r = r + (gray - r) * grayscale;
134
+ g = g + (gray - g) * grayscale;
135
+ b = b + (gray - b) * grayscale;
136
+ const sr = 0.393 * r + 0.769 * g + 0.189 * b;
137
+ const sg = 0.349 * r + 0.686 * g + 0.168 * b;
138
+ const sb = 0.272 * r + 0.534 * g + 0.131 * b;
139
+ output.data[index] = clampByte(r + (sr - r) * sepia);
140
+ output.data[index + 1] = clampByte(g + (sg - g) * sepia);
141
+ output.data[index + 2] = clampByte(b + (sb - b) * sepia);
142
+ }
143
+ return output;
144
+ }
145
+
146
+ export function applyOperationsToPixels(source: PixelSurface, operations: readonly ImageEditorOperation[]): PixelSurface {
147
+ return operations.reduce<PixelSurface>((surface, operation) => {
148
+ if (operation.kind === 'transform') return transformPixels(surface, operation);
149
+ if (operation.kind === 'crop') return cropPixels(surface, operation.rect, operation.output);
150
+ return filterPixels(surface, combinedFilters(operation.filters, operation.preset));
151
+ }, copySurface(source));
152
+ }
153
+
154
+ function canvas2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
155
+ const context = canvas.getContext('2d', { willReadFrequently: true });
156
+ if (!context) throw new ImageEditorError();
157
+ return context;
158
+ }
159
+
160
+ function pixelsFromDrawable(drawable: CanvasImageSource, size: ImageSize): PixelSurface {
161
+ const canvas = document.createElement('canvas');
162
+ canvas.width = size.width;
163
+ canvas.height = size.height;
164
+ const context = canvas2d(canvas);
165
+ context.drawImage(drawable, 0, 0, size.width, size.height);
166
+ try {
167
+ const image = context.getImageData(0, 0, size.width, size.height);
168
+ return { width: size.width, height: size.height, data: new Uint8ClampedArray(image.data) };
169
+ } catch {
170
+ throw new ImageEditorError();
171
+ }
172
+ }
173
+
174
+ async function decodeBlob(blob: Blob): Promise<{ drawable: CanvasImageSource; width: number; height: number; dispose: () => void }> {
175
+ if (typeof createImageBitmap === 'function') {
176
+ try {
177
+ const bitmap = await createImageBitmap(blob);
178
+ if (!bitmap.width || !bitmap.height) { bitmap.close(); throw new ImageEditorError(); }
179
+ return { drawable: bitmap, width: bitmap.width, height: bitmap.height, dispose: () => bitmap.close() };
180
+ } catch {
181
+ // Some browsers expose createImageBitmap but do not decode every
182
+ // image MIME it accepts through <img>; use the owned-URL fallback.
183
+ }
184
+ }
185
+ const ownedUrl = URL.createObjectURL(blob);
186
+ try {
187
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
188
+ const element = new Image();
189
+ element.onload = () => resolve(element);
190
+ element.onerror = () => reject(new ImageEditorError('The selected image could not be decoded.'));
191
+ element.src = ownedUrl;
192
+ });
193
+ return { drawable: image, width: image.naturalWidth, height: image.naturalHeight, dispose: () => URL.revokeObjectURL(ownedUrl) };
194
+ } catch (error) {
195
+ URL.revokeObjectURL(ownedUrl);
196
+ throw error instanceof ImageEditorError ? error : new ImageEditorError();
197
+ }
198
+ }
199
+
200
+ export async function decodeImageSource(source: ImageEditorSource): Promise<DecodedImage> {
201
+ let blob: Blob;
202
+ let filename = 'edited-image.png';
203
+ if (typeof source === 'string') {
204
+ try {
205
+ const response = await fetch(source, { credentials: 'same-origin' });
206
+ if (!response.ok) throw new ImageEditorError();
207
+ blob = await response.blob();
208
+ } catch {
209
+ throw new ImageEditorError('The image could not be loaded.');
210
+ }
211
+ } else if (source instanceof Blob) {
212
+ blob = source;
213
+ if ('name' in source && typeof source.name === 'string' && source.name) filename = source.name;
214
+ } else {
215
+ throw new ImageEditorError('Select a valid image source.');
216
+ }
217
+ const decoded = await decodeBlob(blob);
218
+ try {
219
+ const pixels = pixelsFromDrawable(decoded.drawable, decoded);
220
+ return { pixels, filename, dispose: decoded.dispose };
221
+ } catch (error) {
222
+ decoded.dispose();
223
+ throw error instanceof ImageEditorError ? error : new ImageEditorError();
224
+ }
225
+ }
226
+
227
+ export function drawPixelSurface(canvas: HTMLCanvasElement, surface: PixelSurface): void {
228
+ canvas.width = surface.width;
229
+ canvas.height = surface.height;
230
+ const context = canvas2d(canvas);
231
+ const image = context.createImageData(surface.width, surface.height);
232
+ image.data.set(surface.data);
233
+ context.putImageData(image, 0, 0);
234
+ }
235
+
236
+ export function pixelSurfaceToBlob(surface: PixelSurface): Promise<Blob> {
237
+ const canvas = document.createElement('canvas');
238
+ drawPixelSurface(canvas, surface);
239
+ return new Promise((resolve, reject) => {
240
+ try {
241
+ canvas.toBlob((blob) => blob ? resolve(blob) : reject(new ImageEditorError('The edited image could not be encoded.')), 'image/png');
242
+ } catch {
243
+ reject(new ImageEditorError('The edited image could not be encoded.'));
244
+ }
245
+ });
246
+ }
247
+
248
+ export async function renderImageOperations(decoded: DecodedImage, operations: readonly ImageEditorOperation[]): Promise<{ pixels: PixelSurface; blob: Blob; width: number; height: number }> {
249
+ const expected = operationOutputSize(decoded.pixels, operations);
250
+ const pixels = applyOperationsToPixels(decoded.pixels, operations);
251
+ if (pixels.width !== expected.width || pixels.height !== expected.height) throw new ImageEditorError();
252
+ const blob = await pixelSurfaceToBlob(pixels);
253
+ return { pixels, blob, width: pixels.width, height: pixels.height };
254
+ }
@@ -0,0 +1,3 @@
1
+ export * from './math';
2
+ export * from './canvas';
3
+ export * from './ImageEditor';
@@ -0,0 +1,416 @@
1
+ /** DOM-free image-editor geometry and operation contracts.
2
+ *
3
+ * Numeric defaults, crop clamp order, transform limits, and filter order are
4
+ * ported from web-mojo's ImageCropView/ImageTransformView/ImageFiltersView.
5
+ * Coordinates are always logical image pixels; preview CSS size and DPR never
6
+ * enter this module.
7
+ */
8
+
9
+ export interface ImageSize {
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export interface ImagePoint {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ export interface ImageRect extends ImagePoint, ImageSize {}
20
+
21
+ export interface CropData extends ImageRect {
22
+ originalWidth: number;
23
+ originalHeight: number;
24
+ }
25
+
26
+ export type CropHandle = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'w' | 'e';
27
+
28
+ export interface CropOptions {
29
+ aspectRatio?: number | null;
30
+ minCropSize?: number;
31
+ fixedCropSize?: ImageSize | null;
32
+ cropAndScale?: ImageSize | null;
33
+ }
34
+
35
+ export interface CanvasMapping {
36
+ scale: number;
37
+ offsetX: number;
38
+ offsetY: number;
39
+ }
40
+
41
+ export interface TransformState {
42
+ scale: number;
43
+ rotation: number;
44
+ translateX: number;
45
+ translateY: number;
46
+ }
47
+
48
+ export interface FilterState {
49
+ brightness: number;
50
+ contrast: number;
51
+ saturation: number;
52
+ hue: number;
53
+ blur: number;
54
+ grayscale: number;
55
+ sepia: number;
56
+ }
57
+
58
+ export type FilterPresetName = keyof typeof FILTER_PRESETS;
59
+
60
+ export interface TransformOperation extends TransformState {
61
+ kind: 'transform';
62
+ }
63
+
64
+ export interface CropOperation {
65
+ kind: 'crop';
66
+ rect: ImageRect;
67
+ output: ImageSize | null;
68
+ }
69
+
70
+ export interface FilterOperation {
71
+ kind: 'filters';
72
+ filters: FilterState;
73
+ preset: FilterPresetName;
74
+ }
75
+
76
+ export type ImageEditorOperation = TransformOperation | CropOperation | FilterOperation;
77
+
78
+ export interface ImageEditorSnapshot {
79
+ operations: readonly ImageEditorOperation[];
80
+ mode: ImageEditorMode;
81
+ }
82
+
83
+ export type ImageEditorMode = 'transform' | 'crop' | 'filters';
84
+
85
+ export const MIN_TRANSFORM_SCALE = 0.1;
86
+ export const MAX_TRANSFORM_SCALE = 5;
87
+ export const TRANSFORM_SCALE_STEP = 0.02;
88
+ export const DEFAULT_MIN_CROP_SIZE = 50;
89
+ export const DEFAULT_HISTORY_LIMIT = 20;
90
+
91
+ export const DEFAULT_TRANSFORM: Readonly<TransformState> = Object.freeze({
92
+ scale: 1,
93
+ rotation: 0,
94
+ translateX: 0,
95
+ translateY: 0,
96
+ });
97
+
98
+ export const DEFAULT_FILTERS: Readonly<FilterState> = Object.freeze({
99
+ brightness: 100,
100
+ contrast: 100,
101
+ saturation: 100,
102
+ hue: 0,
103
+ blur: 0,
104
+ grayscale: 0,
105
+ sepia: 0,
106
+ });
107
+
108
+ export const FILTER_PRESETS = Object.freeze({
109
+ none: Object.freeze({ name: 'Original', filters: Object.freeze({}) }),
110
+ blackWhite: Object.freeze({ name: 'Black & White', filters: Object.freeze({ grayscale: 100 }) }),
111
+ sepia: Object.freeze({ name: 'Sepia', filters: Object.freeze({ sepia: 100 }) }),
112
+ vintage: Object.freeze({ name: 'Vintage', filters: Object.freeze({ sepia: 60, contrast: 110, brightness: 110, saturation: 80 }) }),
113
+ cool: Object.freeze({ name: 'Cool Tones', filters: Object.freeze({ hue: 200, saturation: 120, brightness: 95 }) }),
114
+ warm: Object.freeze({ name: 'Warm Tones', filters: Object.freeze({ hue: 25, saturation: 110, brightness: 105 }) }),
115
+ vibrant: Object.freeze({ name: 'Vibrant', filters: Object.freeze({ brightness: 105, contrast: 115, saturation: 140, hue: 5 }) }),
116
+ dramatic: Object.freeze({ name: 'Dramatic', filters: Object.freeze({ brightness: 90, contrast: 150, saturation: 120 }) }),
117
+ soft: Object.freeze({ name: 'Soft', filters: Object.freeze({ brightness: 110, contrast: 85, blur: 1 }) }),
118
+ } as const);
119
+
120
+ export const FILTER_ORDER: readonly (keyof FilterState)[] = Object.freeze([
121
+ 'brightness', 'contrast', 'saturation', 'hue', 'blur', 'grayscale', 'sepia',
122
+ ]);
123
+
124
+ export const CROP_HANDLES: Readonly<Record<CropHandle, Readonly<ImagePoint>>> = Object.freeze({
125
+ nw: Object.freeze({ x: 0, y: 0 }),
126
+ ne: Object.freeze({ x: 1, y: 0 }),
127
+ sw: Object.freeze({ x: 0, y: 1 }),
128
+ se: Object.freeze({ x: 1, y: 1 }),
129
+ n: Object.freeze({ x: 0.5, y: 0 }),
130
+ s: Object.freeze({ x: 0.5, y: 1 }),
131
+ w: Object.freeze({ x: 0, y: 0.5 }),
132
+ e: Object.freeze({ x: 1, y: 0.5 }),
133
+ });
134
+
135
+ export function canvasMapping(image: ImageSize, canvas: ImageSize, autoFit = true): CanvasMapping {
136
+ const scaleX = canvas.width / image.width;
137
+ const scaleY = canvas.height / image.height;
138
+ const scale = autoFit ? Math.min(scaleX, scaleY, 1) : 1;
139
+ return {
140
+ scale,
141
+ offsetX: (canvas.width - image.width * scale) / 2,
142
+ offsetY: (canvas.height - image.height * scale) / 2,
143
+ };
144
+ }
145
+
146
+ export function imageToCanvas(rect: ImageRect, mapping: CanvasMapping): ImageRect {
147
+ return {
148
+ x: rect.x * mapping.scale + mapping.offsetX,
149
+ y: rect.y * mapping.scale + mapping.offsetY,
150
+ width: rect.width * mapping.scale,
151
+ height: rect.height * mapping.scale,
152
+ };
153
+ }
154
+
155
+ export function canvasToImage(rect: ImageRect, mapping: CanvasMapping): ImageRect {
156
+ return {
157
+ x: (rect.x - mapping.offsetX) / mapping.scale,
158
+ y: (rect.y - mapping.offsetY) / mapping.scale,
159
+ width: rect.width / mapping.scale,
160
+ height: rect.height / mapping.scale,
161
+ };
162
+ }
163
+
164
+ export function initializeCropBox(image: ImageSize, options: CropOptions = {}): ImageRect {
165
+ const minCropSize = options.minCropSize ?? DEFAULT_MIN_CROP_SIZE;
166
+ let cropWidth: number;
167
+ let cropHeight: number;
168
+
169
+ if (options.fixedCropSize) {
170
+ cropWidth = options.fixedCropSize.width;
171
+ cropHeight = options.fixedCropSize.height;
172
+ } else {
173
+ cropWidth = Math.floor(image.width * 0.8);
174
+ cropHeight = Math.floor(image.height * 0.8);
175
+ const aspectRatio = options.cropAndScale
176
+ ? options.cropAndScale.width / options.cropAndScale.height
177
+ : options.aspectRatio;
178
+ if (aspectRatio) {
179
+ if (cropWidth / cropHeight > aspectRatio) cropWidth = cropHeight * aspectRatio;
180
+ else cropHeight = cropWidth / aspectRatio;
181
+ }
182
+ cropWidth = Math.max(minCropSize, cropWidth);
183
+ cropHeight = Math.max(minCropSize, cropHeight);
184
+ }
185
+
186
+ return {
187
+ x: Math.floor((image.width - cropWidth) / 2),
188
+ y: Math.floor((image.height - cropHeight) / 2),
189
+ width: cropWidth,
190
+ height: cropHeight,
191
+ };
192
+ }
193
+
194
+ /** Mutates a copy using ImageCropView's exact minimum/bounds clamp order. */
195
+ export function constrainCropBox(input: ImageRect, image: ImageSize, minCropSize = DEFAULT_MIN_CROP_SIZE): ImageRect {
196
+ const box = { ...input };
197
+ box.width = Math.max(minCropSize, box.width);
198
+ box.height = Math.max(minCropSize, box.height);
199
+ if (box.x < 0) {
200
+ box.width += box.x;
201
+ box.x = 0;
202
+ }
203
+ if (box.y < 0) {
204
+ box.height += box.y;
205
+ box.y = 0;
206
+ }
207
+ if (box.x + box.width > image.width) box.width = image.width - box.x;
208
+ if (box.y + box.height > image.height) box.height = image.height - box.y;
209
+ box.width = Math.max(0, box.width);
210
+ box.height = Math.max(0, box.height);
211
+ return box;
212
+ }
213
+
214
+ export function constrainToAspectRatio(input: ImageRect, handle: CropHandle, ratio: number): ImageRect {
215
+ const box = { ...input };
216
+ if (!ratio) return box;
217
+ let anchorX = 0;
218
+ let anchorY = 0;
219
+
220
+ if (['nw', 'ne', 'sw', 'se'].includes(handle)) {
221
+ if (handle === 'nw') { anchorX = box.x + box.width; anchorY = box.y + box.height; }
222
+ else if (handle === 'ne') { anchorX = box.x; anchorY = box.y + box.height; }
223
+ else if (handle === 'sw') { anchorX = box.x + box.width; anchorY = box.y; }
224
+ else { anchorX = box.x; anchorY = box.y; }
225
+
226
+ if (box.width / box.height > ratio) box.width = box.height * ratio;
227
+ else box.height = box.width / ratio;
228
+
229
+ if (handle === 'nw') { box.x = anchorX - box.width; box.y = anchorY - box.height; }
230
+ else if (handle === 'ne') { box.x = anchorX; box.y = anchorY - box.height; }
231
+ else if (handle === 'sw') { box.x = anchorX - box.width; box.y = anchorY; }
232
+ else { box.x = anchorX; box.y = anchorY; }
233
+ } else if (handle === 'n' || handle === 's') {
234
+ const centerX = box.x + box.width / 2;
235
+ box.width = box.height * ratio;
236
+ box.x = centerX - box.width / 2;
237
+ } else {
238
+ const centerY = box.y + box.height / 2;
239
+ box.height = box.width / ratio;
240
+ box.y = centerY - box.height / 2;
241
+ }
242
+ return box;
243
+ }
244
+
245
+ export function resizeCropBox(
246
+ initial: ImageRect,
247
+ dragStart: ImagePoint,
248
+ current: ImagePoint,
249
+ handle: CropHandle,
250
+ image: ImageSize,
251
+ options: CropOptions = {},
252
+ ): ImageRect {
253
+ const deltaX = current.x - dragStart.x;
254
+ const deltaY = current.y - dragStart.y;
255
+ let box = { ...initial };
256
+ if (handle === 'nw') { box.x += deltaX; box.y += deltaY; box.width -= deltaX; box.height -= deltaY; }
257
+ else if (handle === 'ne') { box.y += deltaY; box.width += deltaX; box.height -= deltaY; }
258
+ else if (handle === 'sw') { box.x += deltaX; box.width -= deltaX; box.height += deltaY; }
259
+ else if (handle === 'se') { box.width += deltaX; box.height += deltaY; }
260
+ else if (handle === 'n') { box.y += deltaY; box.height -= deltaY; }
261
+ else if (handle === 's') box.height += deltaY;
262
+ else if (handle === 'w') { box.x += deltaX; box.width -= deltaX; }
263
+ else box.width += deltaX;
264
+
265
+ const ratio = options.cropAndScale
266
+ ? options.cropAndScale.width / options.cropAndScale.height
267
+ : options.aspectRatio;
268
+ if (ratio) box = constrainToAspectRatio(box, handle, ratio);
269
+ return constrainCropBox(box, image, options.minCropSize ?? DEFAULT_MIN_CROP_SIZE);
270
+ }
271
+
272
+ export function newCropBox(start: ImagePoint, current: ImagePoint, image: ImageSize, options: CropOptions = {}): ImageRect {
273
+ let box: ImageRect = {
274
+ x: Math.min(start.x, current.x),
275
+ y: Math.min(start.y, current.y),
276
+ width: Math.abs(current.x - start.x),
277
+ height: Math.abs(current.y - start.y),
278
+ };
279
+ const ratio = options.cropAndScale
280
+ ? options.cropAndScale.width / options.cropAndScale.height
281
+ : options.aspectRatio;
282
+ if (ratio) box = constrainToAspectRatio(box, 'se', ratio);
283
+ const min = options.minCropSize ?? DEFAULT_MIN_CROP_SIZE;
284
+ if (box.width < min) box.width = min;
285
+ if (box.height < min) box.height = min;
286
+ return constrainCropBox(box, image, min);
287
+ }
288
+
289
+ export function moveCropBox(initial: ImageRect, delta: ImagePoint, image: ImageSize): ImageRect {
290
+ return {
291
+ x: Math.max(0, Math.min(image.width - initial.width, initial.x + delta.x)),
292
+ y: Math.max(0, Math.min(image.height - initial.height, initial.y + delta.y)),
293
+ width: initial.width,
294
+ height: initial.height,
295
+ };
296
+ }
297
+
298
+ /** CropView's export/getCropData order intentionally uses the original x/y in the width/height clamps. */
299
+ export function cropDataFromBox(box: ImageRect, image: ImageSize): CropData {
300
+ return {
301
+ x: Math.max(0, Math.min(box.x, image.width)),
302
+ y: Math.max(0, Math.min(box.y, image.height)),
303
+ width: Math.min(box.width, image.width - box.x),
304
+ height: Math.min(box.height, image.height - box.y),
305
+ originalWidth: image.width,
306
+ originalHeight: image.height,
307
+ };
308
+ }
309
+
310
+ export function clampTransformScale(scale: number): number {
311
+ return Math.max(MIN_TRANSFORM_SCALE, Math.min(MAX_TRANSFORM_SCALE, scale));
312
+ }
313
+
314
+ export function rotateTransform(state: TransformState, degrees: number): TransformState {
315
+ let rotation = (state.rotation + degrees) % 360;
316
+ if (rotation < 0) rotation += 360;
317
+ return { ...state, rotation };
318
+ }
319
+
320
+ export function zoomTransformAtPoint(state: TransformState, scale: number, point: ImagePoint, canvas: ImageSize): TransformState {
321
+ const oldScale = state.scale;
322
+ const nextScale = clampTransformScale(scale);
323
+ if (oldScale === nextScale) return { ...state };
324
+ const scaleDiff = nextScale / oldScale;
325
+ const centerX = canvas.width / 2;
326
+ const centerY = canvas.height / 2;
327
+ return {
328
+ ...state,
329
+ scale: nextScale,
330
+ translateX: (state.translateX - (point.x - centerX)) * scaleDiff + (point.x - centerX),
331
+ translateY: (state.translateY - (point.y - centerY)) * scaleDiff + (point.y - centerY),
332
+ };
333
+ }
334
+
335
+ export function transformOutputSize(input: ImageSize, transform: TransformState): ImageSize {
336
+ const radians = transform.rotation * Math.PI / 180;
337
+ const sin = Math.abs(Math.sin(radians));
338
+ const cos = Math.abs(Math.cos(radians));
339
+ return {
340
+ width: Math.max(1, Math.ceil((input.width * cos + input.height * sin) * transform.scale - 1e-10)),
341
+ height: Math.max(1, Math.ceil((input.width * sin + input.height * cos) * transform.scale - 1e-10)),
342
+ };
343
+ }
344
+
345
+ export function combinedFilters(filters: FilterState, preset: FilterPresetName): FilterState {
346
+ const entry = FILTER_PRESETS[preset];
347
+ if (!entry) {
348
+ console.warn(`ImageEditor: unknown filter preset ${JSON.stringify(preset)} — falling back to Original.`);
349
+ return { ...filters };
350
+ }
351
+ return { ...filters, ...entry.filters };
352
+ }
353
+
354
+ export function filterString(filters: FilterState, preset: FilterPresetName = 'none'): string {
355
+ const values = combinedFilters(filters, preset);
356
+ const clean = filters.brightness === 100 && filters.contrast === 100 && filters.saturation === 100
357
+ && filters.hue === 0 && filters.blur === 0 && filters.grayscale === 0 && filters.sepia === 0;
358
+ if (clean && preset === 'none') return 'none';
359
+ return [
360
+ `brightness(${values.brightness}%)`,
361
+ `contrast(${values.contrast}%)`,
362
+ `saturate(${values.saturation}%)`,
363
+ `hue-rotate(${values.hue}deg)`,
364
+ `blur(${values.blur}px)`,
365
+ `grayscale(${values.grayscale}%)`,
366
+ `sepia(${values.sepia}%)`,
367
+ ].join(' ');
368
+ }
369
+
370
+ export function appendOperation(
371
+ operations: readonly ImageEditorOperation[],
372
+ operation: ImageEditorOperation,
373
+ ): readonly ImageEditorOperation[] {
374
+ return Object.freeze([...operations, Object.freeze(operation)]);
375
+ }
376
+
377
+ export function operationOutputSize(source: ImageSize, operations: readonly ImageEditorOperation[]): ImageSize {
378
+ return operations.reduce<ImageSize>((size, operation) => {
379
+ if (operation.kind === 'transform') return transformOutputSize(size, operation);
380
+ if (operation.kind === 'crop') return operation.output ?? {
381
+ width: Math.max(1, Math.floor(operation.rect.width)),
382
+ height: Math.max(1, Math.floor(operation.rect.height)),
383
+ };
384
+ return size;
385
+ }, source);
386
+ }
387
+
388
+ export function lastCropData(source: ImageSize, operations: readonly ImageEditorOperation[]): CropData | null {
389
+ let size = source;
390
+ let latest: CropData | null = null;
391
+ for (const operation of operations) {
392
+ if (operation.kind === 'transform') size = transformOutputSize(size, operation);
393
+ else if (operation.kind === 'crop') {
394
+ latest = cropDataFromBox(operation.rect, size);
395
+ size = operation.output ?? {
396
+ width: Math.max(1, Math.floor(operation.rect.width)),
397
+ height: Math.max(1, Math.floor(operation.rect.height)),
398
+ };
399
+ }
400
+ }
401
+ return latest;
402
+ }
403
+
404
+ export function commitSnapshot(
405
+ history: readonly ImageEditorSnapshot[],
406
+ index: number,
407
+ snapshot: ImageEditorSnapshot,
408
+ maxHistory = DEFAULT_HISTORY_LIMIT,
409
+ ): { history: readonly ImageEditorSnapshot[]; index: number } {
410
+ const next = [...history.slice(0, index + 1), Object.freeze({
411
+ mode: snapshot.mode,
412
+ operations: Object.freeze([...snapshot.operations]),
413
+ })];
414
+ const bounded = next.length > maxHistory ? next.slice(next.length - maxHistory) : next;
415
+ return { history: Object.freeze(bounded), index: bounded.length - 1 };
416
+ }