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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback, useEffect, useMemo, useRef, useState,
|
|
3
|
+
type PointerEvent as ReactPointerEvent,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import { modal, type ModalSize } from '../modal';
|
|
6
|
+
import {
|
|
7
|
+
applyOperationsToPixels, decodeImageSource, drawPixelSurface, pixelSurfaceToBlob,
|
|
8
|
+
ImageEditorError, type DecodedImage, type ImageEditorSource,
|
|
9
|
+
} from './canvas';
|
|
10
|
+
import {
|
|
11
|
+
CROP_HANDLES, DEFAULT_FILTERS, DEFAULT_HISTORY_LIMIT, DEFAULT_TRANSFORM,
|
|
12
|
+
FILTER_PRESETS, TRANSFORM_SCALE_STEP, appendOperation, canvasMapping,
|
|
13
|
+
commitSnapshot, cropDataFromBox, filterString, imageToCanvas, initializeCropBox,
|
|
14
|
+
lastCropData, moveCropBox, newCropBox, operationOutputSize, resizeCropBox,
|
|
15
|
+
rotateTransform, type CropHandle, type CropOptions, type FilterPresetName,
|
|
16
|
+
type FilterState, type ImageEditorMode, type ImageEditorOperation,
|
|
17
|
+
type ImageEditorSnapshot, type ImagePoint, type ImageRect, type TransformState,
|
|
18
|
+
} from './math';
|
|
19
|
+
|
|
20
|
+
const ALL_MODES: readonly ImageEditorMode[] = ['transform', 'crop', 'filters'];
|
|
21
|
+
|
|
22
|
+
export interface ImageEditorResult {
|
|
23
|
+
blob: Blob;
|
|
24
|
+
filename: string;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
cropData: ReturnType<typeof cropDataFromBox> | null;
|
|
28
|
+
operations: readonly ImageEditorOperation[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ImageEditorOptions {
|
|
32
|
+
filename?: string;
|
|
33
|
+
startMode?: ImageEditorMode | string;
|
|
34
|
+
modes?: readonly (ImageEditorMode | string)[];
|
|
35
|
+
crop?: CropOptions;
|
|
36
|
+
initialOperations?: readonly ImageEditorOperation[];
|
|
37
|
+
maxHistory?: number;
|
|
38
|
+
saveText?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ImageEditorProps extends ImageEditorOptions {
|
|
42
|
+
source: ImageEditorSource;
|
|
43
|
+
onSave: (result: ImageEditorResult) => void | Promise<void>;
|
|
44
|
+
onCancel?: () => void;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
onBusyChange?: (busy: boolean) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ImageEditorModalOptions extends ImageEditorOptions {
|
|
50
|
+
title?: string;
|
|
51
|
+
size?: ModalSize;
|
|
52
|
+
/** Optional owner lifecycle signal; abort closes the editor with null. */
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Timeline {
|
|
57
|
+
history: readonly ImageEditorSnapshot[];
|
|
58
|
+
index: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface DragState {
|
|
62
|
+
pointerId: number;
|
|
63
|
+
kind: 'pan' | 'move-crop' | 'resize-crop' | 'new-crop';
|
|
64
|
+
start: ImagePoint;
|
|
65
|
+
initialTransform: TransformState;
|
|
66
|
+
initialCrop: ImageRect;
|
|
67
|
+
initialDirty: boolean;
|
|
68
|
+
handle?: CropHandle;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function positiveFinite(value: unknown): value is number {
|
|
72
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateSize(size: CropOptions['fixedCropSize'], label: string): void {
|
|
76
|
+
if (size == null) return;
|
|
77
|
+
if (!positiveFinite(size.width) || !positiveFinite(size.height) || !Number.isInteger(size.width) || !Number.isInteger(size.height)) {
|
|
78
|
+
throw new TypeError(`${label} must use positive finite integer dimensions.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateImageEditorOptions(source: ImageEditorSource, options: ImageEditorOptions): void {
|
|
83
|
+
if (!(typeof source === 'string' && source.length > 0) && !(typeof Blob !== 'undefined' && source instanceof Blob)) {
|
|
84
|
+
throw new TypeError('ImageEditor source must be a non-empty URL or Blob.');
|
|
85
|
+
}
|
|
86
|
+
if (options.crop?.aspectRatio != null && !positiveFinite(options.crop.aspectRatio)) {
|
|
87
|
+
throw new TypeError('ImageEditor crop aspectRatio must be positive and finite.');
|
|
88
|
+
}
|
|
89
|
+
if (options.crop?.minCropSize != null && !positiveFinite(options.crop.minCropSize)) {
|
|
90
|
+
throw new TypeError('ImageEditor minCropSize must be positive and finite.');
|
|
91
|
+
}
|
|
92
|
+
validateSize(options.crop?.fixedCropSize, 'ImageEditor fixedCropSize');
|
|
93
|
+
validateSize(options.crop?.cropAndScale, 'ImageEditor cropAndScale');
|
|
94
|
+
if (options.maxHistory != null && (!Number.isInteger(options.maxHistory) || options.maxHistory < 1 || options.maxHistory > DEFAULT_HISTORY_LIMIT)) {
|
|
95
|
+
throw new TypeError(`ImageEditor maxHistory must be an integer from 1 to ${DEFAULT_HISTORY_LIMIT}.`);
|
|
96
|
+
}
|
|
97
|
+
for (const operation of options.initialOperations ?? []) {
|
|
98
|
+
if (operation.kind === 'transform') {
|
|
99
|
+
if (![operation.scale, operation.rotation, operation.translateX, operation.translateY].every(Number.isFinite) || operation.scale <= 0) {
|
|
100
|
+
throw new TypeError('ImageEditor transform operations require finite values and a positive scale.');
|
|
101
|
+
}
|
|
102
|
+
} else if (operation.kind === 'crop') {
|
|
103
|
+
if (![operation.rect.x, operation.rect.y, operation.rect.width, operation.rect.height].every(Number.isFinite)
|
|
104
|
+
|| operation.rect.width <= 0 || operation.rect.height <= 0) {
|
|
105
|
+
throw new TypeError('ImageEditor crop operations require finite coordinates and positive dimensions.');
|
|
106
|
+
}
|
|
107
|
+
validateSize(operation.output, 'ImageEditor crop output');
|
|
108
|
+
} else if (operation.kind === 'filters') {
|
|
109
|
+
if (!Object.values(operation.filters).every(Number.isFinite)) throw new TypeError('ImageEditor filter operations require finite values.');
|
|
110
|
+
} else {
|
|
111
|
+
throw new TypeError('ImageEditor received an unknown operation.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeModes(modes: ImageEditorOptions['modes']): readonly ImageEditorMode[] {
|
|
117
|
+
if (!modes?.length) return ALL_MODES;
|
|
118
|
+
const known: ImageEditorMode[] = [];
|
|
119
|
+
for (const candidate of modes) {
|
|
120
|
+
if (ALL_MODES.includes(candidate as ImageEditorMode)) {
|
|
121
|
+
if (!known.includes(candidate as ImageEditorMode)) known.push(candidate as ImageEditorMode);
|
|
122
|
+
} else {
|
|
123
|
+
console.warn(`ImageEditor: unknown mode ${JSON.stringify(candidate)} — ignoring it.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (known.length) return known;
|
|
127
|
+
console.warn('ImageEditor: no known modes remained — falling back to transform.');
|
|
128
|
+
return ['transform'];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeMode(mode: string | undefined, modes: readonly ImageEditorMode[]): ImageEditorMode {
|
|
132
|
+
if (mode && modes.includes(mode as ImageEditorMode)) return mode as ImageEditorMode;
|
|
133
|
+
if (mode) console.warn(`ImageEditor: unavailable start mode ${JSON.stringify(mode)} — falling back to ${modes[0]}.`);
|
|
134
|
+
return modes[0]!;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function outputFilename(requested: string | undefined, decoded: string): string {
|
|
138
|
+
const candidate = requested?.trim() || decoded.trim() || 'edited-image.png';
|
|
139
|
+
const safe = candidate.replace(/[\\/\r\n]/g, '-');
|
|
140
|
+
return safe.toLowerCase().endsWith('.png') ? safe : `${safe.replace(/\.[^.]+$/, '')}-edited.png`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function transformDirty(state: TransformState): boolean {
|
|
144
|
+
return state.scale !== 1 || state.rotation !== 0 || state.translateX !== 0 || state.translateY !== 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function filterDirty(filters: FilterState, preset: FilterPresetName): boolean {
|
|
148
|
+
return preset !== 'none' || filterString(filters) !== 'none';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function ImageEditor(props: ImageEditorProps) {
|
|
152
|
+
validateImageEditorOptions(props.source, props);
|
|
153
|
+
const modes = useMemo(() => normalizeModes(props.modes), [props.modes]);
|
|
154
|
+
const initialMode = normalizeMode(props.startMode, modes);
|
|
155
|
+
const maxHistory = props.maxHistory ?? DEFAULT_HISTORY_LIMIT;
|
|
156
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
157
|
+
const overlayRef = useRef<SVGSVGElement>(null);
|
|
158
|
+
const decodedRef = useRef<DecodedImage | null>(null);
|
|
159
|
+
const loadGeneration = useRef(0);
|
|
160
|
+
const saveGeneration = useRef(0);
|
|
161
|
+
const savingRef = useRef(false);
|
|
162
|
+
const drag = useRef<DragState | null>(null);
|
|
163
|
+
const [decoded, setDecoded] = useState<DecodedImage | null>(null);
|
|
164
|
+
const [error, setError] = useState<string | null>(null);
|
|
165
|
+
const [busy, setBusy] = useState(false);
|
|
166
|
+
const [mode, setMode] = useState<ImageEditorMode>(initialMode);
|
|
167
|
+
const [timeline, setTimeline] = useState<Timeline>({ history: [], index: -1 });
|
|
168
|
+
const [transform, setTransform] = useState<TransformState>({ ...DEFAULT_TRANSFORM });
|
|
169
|
+
const [cropBox, setCropBox] = useState<ImageRect>({ x: 0, y: 0, width: 0, height: 0 });
|
|
170
|
+
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS });
|
|
171
|
+
const [preset, setPreset] = useState<FilterPresetName>('none');
|
|
172
|
+
const [dirty, setDirty] = useState(false);
|
|
173
|
+
|
|
174
|
+
const committed = timeline.index >= 0 ? timeline.history[timeline.index]!.operations : (props.initialOperations ?? []);
|
|
175
|
+
const sourceSize = decoded?.pixels ?? { width: 1, height: 1 };
|
|
176
|
+
const workingSize = operationOutputSize(sourceSize, committed);
|
|
177
|
+
const cropOptions = useMemo(() => props.crop ?? {}, [props.crop]);
|
|
178
|
+
|
|
179
|
+
const resetDraft = useCallback((nextMode: ImageEditorMode, size = workingSize) => {
|
|
180
|
+
setTransform({ ...DEFAULT_TRANSFORM });
|
|
181
|
+
setFilters({ ...DEFAULT_FILTERS });
|
|
182
|
+
setPreset('none');
|
|
183
|
+
setCropBox(initializeCropBox(size, cropOptions));
|
|
184
|
+
setDirty(false);
|
|
185
|
+
setMode(nextMode);
|
|
186
|
+
}, [cropOptions, workingSize]);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const generation = ++loadGeneration.current;
|
|
190
|
+
let live = true;
|
|
191
|
+
void decodeImageSource(props.source).then((next) => {
|
|
192
|
+
if (!live || generation !== loadGeneration.current) { next.dispose(); return; }
|
|
193
|
+
const operations = Object.freeze([...(props.initialOperations ?? [])]);
|
|
194
|
+
const snapshot: ImageEditorSnapshot = Object.freeze({ mode: initialMode, operations });
|
|
195
|
+
decodedRef.current?.dispose();
|
|
196
|
+
decodedRef.current = next;
|
|
197
|
+
setDecoded(next);
|
|
198
|
+
setTimeline({ history: Object.freeze([snapshot]), index: 0 });
|
|
199
|
+
setError(null);
|
|
200
|
+
const size = operationOutputSize(next.pixels, operations);
|
|
201
|
+
setTransform({ ...DEFAULT_TRANSFORM });
|
|
202
|
+
setFilters({ ...DEFAULT_FILTERS });
|
|
203
|
+
setPreset('none');
|
|
204
|
+
setCropBox(initializeCropBox(size, cropOptions));
|
|
205
|
+
setDirty(false);
|
|
206
|
+
setMode(initialMode);
|
|
207
|
+
}, () => {
|
|
208
|
+
if (live && generation === loadGeneration.current) setError('The image could not be loaded. Your current edit is unchanged.');
|
|
209
|
+
});
|
|
210
|
+
return () => { live = false; };
|
|
211
|
+
}, [cropOptions, initialMode, props.initialOperations, props.source]);
|
|
212
|
+
|
|
213
|
+
useEffect(() => () => {
|
|
214
|
+
loadGeneration.current += 1;
|
|
215
|
+
saveGeneration.current += 1;
|
|
216
|
+
decodedRef.current?.dispose();
|
|
217
|
+
decodedRef.current = null;
|
|
218
|
+
}, []);
|
|
219
|
+
|
|
220
|
+
const operationForMode = useCallback((forceCrop = false): ImageEditorOperation | null => {
|
|
221
|
+
if (mode === 'transform') return transformDirty(transform) ? { kind: 'transform', ...transform } : null;
|
|
222
|
+
if (mode === 'filters') return filterDirty(filters, preset) ? { kind: 'filters', filters: { ...filters }, preset } : null;
|
|
223
|
+
return dirty || forceCrop ? { kind: 'crop', rect: { ...cropBox }, output: cropOptions.cropAndScale ? { ...cropOptions.cropAndScale } : null } : null;
|
|
224
|
+
}, [cropBox, cropOptions.cropAndScale, dirty, filters, mode, preset, transform]);
|
|
225
|
+
|
|
226
|
+
const previewOperations = useMemo(() => {
|
|
227
|
+
const draft = operationForMode(false);
|
|
228
|
+
return draft && draft.kind !== 'crop' ? appendOperation(committed, draft) : committed;
|
|
229
|
+
}, [committed, operationForMode]);
|
|
230
|
+
const preview = useMemo(() => decoded ? applyOperationsToPixels(decoded.pixels, previewOperations) : null, [decoded, previewOperations]);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!canvasRef.current || !preview) return;
|
|
234
|
+
try { drawPixelSurface(canvasRef.current, preview); setError(null); }
|
|
235
|
+
catch { setError('The image preview could not be rendered. Your edit is unchanged.'); }
|
|
236
|
+
}, [preview]);
|
|
237
|
+
|
|
238
|
+
useEffect(() => { props.onBusyChange?.(busy); return () => props.onBusyChange?.(false); }, [busy, props.onBusyChange]);
|
|
239
|
+
|
|
240
|
+
const commit = useCallback((nextMode = mode, forceCrop = false): readonly ImageEditorOperation[] => {
|
|
241
|
+
const operation = operationForMode(forceCrop);
|
|
242
|
+
const nextOperations = operation ? appendOperation(committed, operation) : committed;
|
|
243
|
+
if (operation) {
|
|
244
|
+
setTimeline((current) => commitSnapshot(current.history, current.index, { mode: nextMode, operations: nextOperations }, maxHistory));
|
|
245
|
+
}
|
|
246
|
+
const nextSize = operationOutputSize(sourceSize, nextOperations);
|
|
247
|
+
resetDraft(nextMode, nextSize);
|
|
248
|
+
return nextOperations;
|
|
249
|
+
}, [committed, maxHistory, mode, operationForMode, resetDraft, sourceSize]);
|
|
250
|
+
|
|
251
|
+
const switchMode = (nextMode: ImageEditorMode) => {
|
|
252
|
+
if (nextMode === mode || busy || props.disabled) return;
|
|
253
|
+
if (dirty || transformDirty(transform) || filterDirty(filters, preset)) commit(nextMode);
|
|
254
|
+
else resetDraft(nextMode);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const undo = () => {
|
|
258
|
+
if (busy || props.disabled || timeline.index <= 0) return;
|
|
259
|
+
const index = timeline.index - 1;
|
|
260
|
+
const snapshot = timeline.history[index]!;
|
|
261
|
+
setTimeline({ ...timeline, index });
|
|
262
|
+
resetDraft(snapshot.mode, operationOutputSize(sourceSize, snapshot.operations));
|
|
263
|
+
};
|
|
264
|
+
const redo = () => {
|
|
265
|
+
if (busy || props.disabled || timeline.index >= timeline.history.length - 1) return;
|
|
266
|
+
const index = timeline.index + 1;
|
|
267
|
+
const snapshot = timeline.history[index]!;
|
|
268
|
+
setTimeline({ ...timeline, index });
|
|
269
|
+
resetDraft(snapshot.mode, operationOutputSize(sourceSize, snapshot.operations));
|
|
270
|
+
};
|
|
271
|
+
const reset = () => {
|
|
272
|
+
if (busy || props.disabled) return;
|
|
273
|
+
const snapshot: ImageEditorSnapshot = Object.freeze({ mode: modes[0]!, operations: Object.freeze([]) });
|
|
274
|
+
setTimeline({ history: Object.freeze([snapshot]), index: 0 });
|
|
275
|
+
resetDraft(modes[0]!, sourceSize);
|
|
276
|
+
setError(null);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const save = async () => {
|
|
280
|
+
if (!decoded || savingRef.current || props.disabled) return;
|
|
281
|
+
savingRef.current = true;
|
|
282
|
+
const generation = ++saveGeneration.current;
|
|
283
|
+
setBusy(true);
|
|
284
|
+
props.onBusyChange?.(true);
|
|
285
|
+
setError(null);
|
|
286
|
+
try {
|
|
287
|
+
const draft = operationForMode(mode === 'crop');
|
|
288
|
+
const finalOperations = draft ? appendOperation(committed, draft) : committed;
|
|
289
|
+
const pixels = applyOperationsToPixels(decoded.pixels, finalOperations);
|
|
290
|
+
const blob = await pixelSurfaceToBlob(pixels);
|
|
291
|
+
if (generation !== saveGeneration.current) return;
|
|
292
|
+
const result: ImageEditorResult = {
|
|
293
|
+
blob,
|
|
294
|
+
filename: outputFilename(props.filename, decoded.filename),
|
|
295
|
+
width: pixels.width,
|
|
296
|
+
height: pixels.height,
|
|
297
|
+
cropData: lastCropData(decoded.pixels, finalOperations),
|
|
298
|
+
operations: Object.freeze([...finalOperations]),
|
|
299
|
+
};
|
|
300
|
+
await props.onSave(result);
|
|
301
|
+
} catch (caught) {
|
|
302
|
+
if (generation === saveGeneration.current) setError(caught instanceof ImageEditorError ? caught.message : 'The edited image could not be saved.');
|
|
303
|
+
} finally {
|
|
304
|
+
if (generation === saveGeneration.current) {
|
|
305
|
+
savingRef.current = false;
|
|
306
|
+
setBusy(false);
|
|
307
|
+
props.onBusyChange?.(false);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const pointFor = (event: ReactPointerEvent, element: Element): ImagePoint => {
|
|
313
|
+
const rect = element.getBoundingClientRect();
|
|
314
|
+
return { x: (event.clientX - rect.left) * workingSize.width / rect.width, y: (event.clientY - rect.top) * workingSize.height / rect.height };
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const beginPointer = (event: ReactPointerEvent<SVGSVGElement | HTMLCanvasElement>) => {
|
|
318
|
+
if (busy || props.disabled || event.button !== 0) return;
|
|
319
|
+
const element = event.currentTarget;
|
|
320
|
+
const start = pointFor(event, element);
|
|
321
|
+
let kind: DragState['kind'] = 'pan';
|
|
322
|
+
let handle: CropHandle | undefined;
|
|
323
|
+
if (mode === 'crop') {
|
|
324
|
+
const target = event.target as Element;
|
|
325
|
+
const rawHandle = target.getAttribute('data-crop-handle');
|
|
326
|
+
if (rawHandle && rawHandle in CROP_HANDLES) { kind = 'resize-crop'; handle = rawHandle as CropHandle; }
|
|
327
|
+
else if (target.getAttribute('data-crop-box') === 'true') kind = 'move-crop';
|
|
328
|
+
else kind = 'new-crop';
|
|
329
|
+
} else if (mode !== 'transform') return;
|
|
330
|
+
drag.current = { pointerId: event.pointerId, kind, start, initialTransform: { ...transform }, initialCrop: { ...cropBox }, initialDirty: dirty, handle };
|
|
331
|
+
(element.closest('.image-editor') as HTMLElement | null)?.focus();
|
|
332
|
+
element.setPointerCapture(event.pointerId);
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const movePointer = (event: ReactPointerEvent<SVGSVGElement | HTMLCanvasElement>) => {
|
|
337
|
+
const active = drag.current;
|
|
338
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
339
|
+
const point = pointFor(event, event.currentTarget);
|
|
340
|
+
const delta = { x: point.x - active.start.x, y: point.y - active.start.y };
|
|
341
|
+
if (active.kind === 'pan') setTransform({ ...active.initialTransform, translateX: active.initialTransform.translateX + delta.x, translateY: active.initialTransform.translateY + delta.y });
|
|
342
|
+
else if (active.kind === 'move-crop') setCropBox(moveCropBox(active.initialCrop, delta, workingSize));
|
|
343
|
+
else if (active.kind === 'resize-crop' && active.handle) setCropBox(resizeCropBox(active.initialCrop, active.start, point, active.handle, workingSize, cropOptions));
|
|
344
|
+
else setCropBox(newCropBox(active.start, point, workingSize, cropOptions));
|
|
345
|
+
setDirty(true);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const endPointer = (event: ReactPointerEvent<SVGSVGElement | HTMLCanvasElement>) => {
|
|
349
|
+
if (drag.current?.pointerId !== event.pointerId) return;
|
|
350
|
+
drag.current = null;
|
|
351
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
352
|
+
};
|
|
353
|
+
const cancelPointer = (event: ReactPointerEvent<SVGSVGElement | HTMLCanvasElement>) => {
|
|
354
|
+
const active = drag.current;
|
|
355
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
356
|
+
setTransform(active.initialTransform);
|
|
357
|
+
setCropBox(active.initialCrop);
|
|
358
|
+
setDirty(active.initialDirty);
|
|
359
|
+
drag.current = null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const canvasBox = imageToCanvas(cropBox, canvasMapping(workingSize, workingSize));
|
|
363
|
+
const disabled = busy || props.disabled || !decoded;
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div
|
|
367
|
+
className="image-editor"
|
|
368
|
+
tabIndex={-1}
|
|
369
|
+
onKeyDown={(event) => {
|
|
370
|
+
if (event.key === 'Escape' && drag.current) {
|
|
371
|
+
setTransform(drag.current.initialTransform);
|
|
372
|
+
setCropBox(drag.current.initialCrop);
|
|
373
|
+
setDirty(drag.current.initialDirty);
|
|
374
|
+
drag.current = null;
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
event.stopPropagation();
|
|
377
|
+
} else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
if (event.shiftKey) redo(); else undo();
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
<div className="image-editor-toolbar">
|
|
384
|
+
<div className="image-editor-modes" role="group" aria-label="Editing mode">
|
|
385
|
+
{modes.map((candidate) => <button key={candidate} type="button" className={`btn${mode === candidate ? ' btn-primary' : ''}`} disabled={disabled} aria-pressed={mode === candidate} onClick={() => switchMode(candidate)}>{candidate[0]!.toUpperCase() + candidate.slice(1)}</button>)}
|
|
386
|
+
</div>
|
|
387
|
+
<div className="image-editor-history" role="group" aria-label="History">
|
|
388
|
+
<button type="button" className="btn btn-compact" disabled={disabled || timeline.index <= 0} onClick={undo} aria-label="Undo"><i className="bi bi-arrow-counterclockwise" /></button>
|
|
389
|
+
<button type="button" className="btn btn-compact" disabled={disabled || timeline.index >= timeline.history.length - 1} onClick={redo} aria-label="Redo"><i className="bi bi-arrow-clockwise" /></button>
|
|
390
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={reset}>Reset</button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div className="image-editor-controls">
|
|
395
|
+
{mode === 'transform' && <>
|
|
396
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { setTransform((value) => ({ ...value, scale: Math.min(5, value.scale + TRANSFORM_SCALE_STEP) })); setDirty(true); }}>Zoom in</button>
|
|
397
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { setTransform((value) => ({ ...value, scale: Math.max(0.1, value.scale - TRANSFORM_SCALE_STEP) })); setDirty(true); }}>Zoom out</button>
|
|
398
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { setTransform((value) => rotateTransform(value, -90)); setDirty(true); }}>Rotate left</button>
|
|
399
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { setTransform((value) => rotateTransform(value, 90)); setDirty(true); }}>Rotate right</button>
|
|
400
|
+
<button type="button" className="btn btn-compact" disabled={disabled} onClick={() => { setTransform((value) => ({ ...value, translateX: 0, translateY: 0 })); setDirty(true); }}>Center</button>
|
|
401
|
+
<span className="image-editor-value">{Math.round(transform.scale * 100)}% · {transform.rotation}°</span>
|
|
402
|
+
</>}
|
|
403
|
+
{mode === 'crop' && <>
|
|
404
|
+
<span className="image-editor-value">{Math.round(cropBox.width)} × {Math.round(cropBox.height)} px{cropOptions.cropAndScale ? ` → ${cropOptions.cropAndScale.width} × ${cropOptions.cropAndScale.height}` : ''}</span>
|
|
405
|
+
</>}
|
|
406
|
+
{mode === 'filters' && <label className="image-editor-preset">Effect
|
|
407
|
+
<select className="select" value={preset} disabled={disabled} onChange={(event) => {
|
|
408
|
+
const next = event.target.value;
|
|
409
|
+
if (next in FILTER_PRESETS) setPreset(next as FilterPresetName);
|
|
410
|
+
else { console.warn(`ImageEditor: unknown filter preset ${JSON.stringify(next)} — falling back to Original.`); setPreset('none'); }
|
|
411
|
+
setDirty(true);
|
|
412
|
+
}}>
|
|
413
|
+
{Object.entries(FILTER_PRESETS).map(([key, value]) => <option key={key} value={key}>{value.name}</option>)}
|
|
414
|
+
</select>
|
|
415
|
+
</label>}
|
|
416
|
+
<button type="button" className="btn btn-primary btn-compact image-editor-apply" disabled={disabled || (mode !== 'crop' && !dirty && !transformDirty(transform) && !filterDirty(filters, preset))} onClick={() => commit(mode, mode === 'crop')}>Apply</button>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
{mode === 'filters' && <div className="image-editor-sliders">
|
|
420
|
+
{([
|
|
421
|
+
['brightness', 0, 200, '%'], ['contrast', 0, 200, '%'], ['saturation', 0, 200, '%'],
|
|
422
|
+
['hue', 0, 360, '°'], ['blur', 0, 10, 'px'], ['grayscale', 0, 100, '%'], ['sepia', 0, 100, '%'],
|
|
423
|
+
] as const).map(([name, min, max, unit]) => <label key={name}><span>{name} <b>{filters[name]}{unit}</b></span><input type="range" min={min} max={max} value={filters[name]} disabled={disabled} onChange={(event) => { setFilters((value) => ({ ...value, [name]: Number(event.target.value) })); setDirty(true); }} /></label>)}
|
|
424
|
+
</div>}
|
|
425
|
+
|
|
426
|
+
<div className={`image-editor-stage is-${mode}`} aria-busy={!decoded || busy}>
|
|
427
|
+
{!decoded && <div className="image-editor-message">Loading image…</div>}
|
|
428
|
+
<div className="image-editor-preview">
|
|
429
|
+
<canvas ref={canvasRef} className="image-editor-canvas" onPointerDown={beginPointer} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={cancelPointer} />
|
|
430
|
+
{decoded && mode === 'crop' && <svg ref={overlayRef} className="image-editor-crop" viewBox={`0 0 ${workingSize.width} ${workingSize.height}`} preserveAspectRatio="none" onPointerDown={beginPointer} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={cancelPointer}>
|
|
431
|
+
<path className="image-editor-crop-shade" fillRule="evenodd" d={`M0 0H${workingSize.width}V${workingSize.height}H0Z M${canvasBox.x} ${canvasBox.y}V${canvasBox.y + canvasBox.height}H${canvasBox.x + canvasBox.width}V${canvasBox.y}Z`} />
|
|
432
|
+
<rect data-crop-box="true" className="image-editor-crop-box" x={canvasBox.x} y={canvasBox.y} width={canvasBox.width} height={canvasBox.height} />
|
|
433
|
+
{!cropOptions.fixedCropSize && Object.entries(CROP_HANDLES).map(([name, point]) => <rect key={name} data-crop-handle={name} className="image-editor-crop-handle" x={canvasBox.x + canvasBox.width * point.x - 6} y={canvasBox.y + canvasBox.height * point.y - 6} width="12" height="12" vectorEffect="non-scaling-stroke" />)}
|
|
434
|
+
</svg>}
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
{error && <div className="image-editor-error" role="alert">{error}</div>}
|
|
439
|
+
<div className="image-editor-footer">
|
|
440
|
+
<span>{preview?.width ?? 0} × {preview?.height ?? 0}px · {committed.length} committed operation{committed.length === 1 ? '' : 's'}</span>
|
|
441
|
+
<div className="modal-actions">
|
|
442
|
+
{props.onCancel && <button type="button" className="btn" disabled={busy} onClick={props.onCancel}>Cancel</button>}
|
|
443
|
+
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => void save()}>{busy ? 'Saving…' : (props.saveText ?? 'Save image')}</button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function imageEditorModal(source: ImageEditorSource, options: ImageEditorModalOptions = {}): Promise<ImageEditorResult | null> {
|
|
451
|
+
validateImageEditorOptions(source, options);
|
|
452
|
+
let pending = false;
|
|
453
|
+
const { title = 'Image editor', size = 'lg', signal, ...editorOptions } = options;
|
|
454
|
+
return modal.open<ImageEditorResult | null>((close) => (
|
|
455
|
+
<ImageEditorModalBody source={source} title={title} signal={signal} options={editorOptions} close={close} onBusyChange={(value) => { pending = value; }} />
|
|
456
|
+
), { size, flush: true, canDismiss: () => !pending });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function ImageEditorModalBody({ source, title, signal, options, close, onBusyChange }: {
|
|
460
|
+
source: ImageEditorSource;
|
|
461
|
+
title: string;
|
|
462
|
+
signal?: AbortSignal;
|
|
463
|
+
options: ImageEditorOptions;
|
|
464
|
+
close: (result: ImageEditorResult | null) => void;
|
|
465
|
+
onBusyChange: (busy: boolean) => void;
|
|
466
|
+
}) {
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
if (!signal) return;
|
|
469
|
+
if (signal.aborted) { close(null); return; }
|
|
470
|
+
const abort = () => close(null);
|
|
471
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
472
|
+
return () => signal.removeEventListener('abort', abort);
|
|
473
|
+
}, [close, signal]);
|
|
474
|
+
return <div className="image-editor-modal">
|
|
475
|
+
<h2 className="image-editor-modal-title">{title}</h2>
|
|
476
|
+
<ImageEditor
|
|
477
|
+
{...options}
|
|
478
|
+
source={source}
|
|
479
|
+
onSave={(result) => close(result)}
|
|
480
|
+
onCancel={() => close(null)}
|
|
481
|
+
onBusyChange={onBusyChange}
|
|
482
|
+
/>
|
|
483
|
+
</div>;
|
|
484
|
+
}
|