@alpaca-editor/core 1.0.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 (239) hide show
  1. package/.prettierrc +3 -0
  2. package/eslint.config.mjs +4 -0
  3. package/images/bg-shape-black.webp +0 -0
  4. package/package.json +52 -0
  5. package/src/client-components/api.ts +6 -0
  6. package/src/client-components/index.ts +19 -0
  7. package/src/components/ActionButton.tsx +43 -0
  8. package/src/components/Error.tsx +57 -0
  9. package/src/config/config.tsx +737 -0
  10. package/src/config/types.ts +263 -0
  11. package/src/editor/ComponentInfo.tsx +77 -0
  12. package/src/editor/ConfirmationDialog.tsx +103 -0
  13. package/src/editor/ContentTree.tsx +654 -0
  14. package/src/editor/ContextMenu.tsx +155 -0
  15. package/src/editor/Editor.tsx +91 -0
  16. package/src/editor/EditorWarning.tsx +34 -0
  17. package/src/editor/EditorWarnings.tsx +33 -0
  18. package/src/editor/FieldEditorPopup.tsx +65 -0
  19. package/src/editor/FieldHistory.tsx +74 -0
  20. package/src/editor/FieldList.tsx +190 -0
  21. package/src/editor/FieldListField.tsx +387 -0
  22. package/src/editor/FieldListFieldWithFallbacks.tsx +211 -0
  23. package/src/editor/FloatingToolbar.tsx +163 -0
  24. package/src/editor/ImageEditor.tsx +129 -0
  25. package/src/editor/InsertMenu.tsx +332 -0
  26. package/src/editor/ItemInfo.tsx +90 -0
  27. package/src/editor/LinkEditorDialog.tsx +192 -0
  28. package/src/editor/MainLayout.tsx +94 -0
  29. package/src/editor/NewEditorClient.tsx +11 -0
  30. package/src/editor/PictureCropper.tsx +505 -0
  31. package/src/editor/PictureEditor.tsx +206 -0
  32. package/src/editor/PictureEditorDialog.tsx +381 -0
  33. package/src/editor/PublishDialog.ignore +74 -0
  34. package/src/editor/ScrollingContentTree.tsx +47 -0
  35. package/src/editor/Terminal.tsx +215 -0
  36. package/src/editor/Titlebar.tsx +23 -0
  37. package/src/editor/ai/AiPopup.tsx +59 -0
  38. package/src/editor/ai/AiResponseMessage.tsx +82 -0
  39. package/src/editor/ai/AiTerminal.tsx +450 -0
  40. package/src/editor/ai/AiToolCall.tsx +46 -0
  41. package/src/editor/ai/EditorAiTerminal.tsx +20 -0
  42. package/src/editor/ai/editorAiContext.ts +18 -0
  43. package/src/editor/client/DialogContext.tsx +49 -0
  44. package/src/editor/client/EditorClient.tsx +1831 -0
  45. package/src/editor/client/GenericDialog.tsx +50 -0
  46. package/src/editor/client/editContext.ts +330 -0
  47. package/src/editor/client/helpers.ts +44 -0
  48. package/src/editor/client/itemsRepository.ts +391 -0
  49. package/src/editor/client/operations.ts +610 -0
  50. package/src/editor/client/pageModelBuilder.ts +182 -0
  51. package/src/editor/commands/commands.ts +23 -0
  52. package/src/editor/commands/componentCommands.tsx +408 -0
  53. package/src/editor/commands/createVersionCommand.ts +33 -0
  54. package/src/editor/commands/deleteVersionCommand.ts +71 -0
  55. package/src/editor/commands/itemCommands.tsx +186 -0
  56. package/src/editor/commands/localizeItem/LocalizeItemDialog.tsx +201 -0
  57. package/src/editor/commands/undo.ts +39 -0
  58. package/src/editor/component-designer/ComponentDesigner.tsx +70 -0
  59. package/src/editor/component-designer/ComponentDesignerAiTerminal.tsx +11 -0
  60. package/src/editor/component-designer/ComponentDesignerMenu.tsx +91 -0
  61. package/src/editor/component-designer/ComponentEditor.tsx +97 -0
  62. package/src/editor/component-designer/ComponentRenderingCodeEditor.tsx +31 -0
  63. package/src/editor/component-designer/ComponentRenderingEditor.tsx +104 -0
  64. package/src/editor/component-designer/ComponentsDropdown.tsx +39 -0
  65. package/src/editor/component-designer/PlaceholdersEditor.tsx +183 -0
  66. package/src/editor/component-designer/RenderingsDropdown.tsx +36 -0
  67. package/src/editor/component-designer/TemplateEditor.tsx +236 -0
  68. package/src/editor/component-designer/aiContext.ts +23 -0
  69. package/src/editor/componentTreeHelper.tsx +114 -0
  70. package/src/editor/control-center/ControlCenterMenu.tsx +71 -0
  71. package/src/editor/control-center/IndexOverview.tsx +50 -0
  72. package/src/editor/control-center/IndexSettings.tsx +266 -0
  73. package/src/editor/control-center/Status.tsx +7 -0
  74. package/src/editor/editor-warnings/ItemLocked.tsx +63 -0
  75. package/src/editor/editor-warnings/NoLanguageWriteAccess.tsx +22 -0
  76. package/src/editor/editor-warnings/NoWorkflowWriteAccess.tsx +23 -0
  77. package/src/editor/editor-warnings/NoWriteAccess.tsx +15 -0
  78. package/src/editor/editor-warnings/ValidationErrors.tsx +54 -0
  79. package/src/editor/field-types/AttachmentEditor.tsx +9 -0
  80. package/src/editor/field-types/CheckboxEditor.tsx +47 -0
  81. package/src/editor/field-types/DropLinkEditor.tsx +75 -0
  82. package/src/editor/field-types/DropListEditor.tsx +84 -0
  83. package/src/editor/field-types/ImageFieldEditor.tsx +65 -0
  84. package/src/editor/field-types/InternalLinkFieldEditor.tsx +112 -0
  85. package/src/editor/field-types/LinkFieldEditor.tsx +85 -0
  86. package/src/editor/field-types/MultiLineText.tsx +63 -0
  87. package/src/editor/field-types/PictureFieldEditor.tsx +121 -0
  88. package/src/editor/field-types/RawEditor.tsx +53 -0
  89. package/src/editor/field-types/ReactQuill.tsx +580 -0
  90. package/src/editor/field-types/RichTextEditor.tsx +22 -0
  91. package/src/editor/field-types/RichTextEditorComponent.tsx +108 -0
  92. package/src/editor/field-types/SingleLineText.tsx +150 -0
  93. package/src/editor/field-types/TreeListEditor.tsx +261 -0
  94. package/src/editor/fieldTypes.ts +140 -0
  95. package/src/editor/media-selector/AiImageSearch.tsx +186 -0
  96. package/src/editor/media-selector/AiImageSearchPrompt.tsx +95 -0
  97. package/src/editor/media-selector/MediaSelector.tsx +42 -0
  98. package/src/editor/media-selector/Preview.tsx +14 -0
  99. package/src/editor/media-selector/Thumbnails.tsx +48 -0
  100. package/src/editor/media-selector/TreeSelector.tsx +292 -0
  101. package/src/editor/media-selector/UploadZone.tsx +137 -0
  102. package/src/editor/menubar/ActionsMenu.tsx +47 -0
  103. package/src/editor/menubar/ActiveUsers.tsx +17 -0
  104. package/src/editor/menubar/ApproveAndPublish.tsx +18 -0
  105. package/src/editor/menubar/BrowseHistory.tsx +37 -0
  106. package/src/editor/menubar/ItemLanguageVersion.tsx +52 -0
  107. package/src/editor/menubar/LanguageSelector.tsx +152 -0
  108. package/src/editor/menubar/Menu.tsx +83 -0
  109. package/src/editor/menubar/NavButtons.tsx +74 -0
  110. package/src/editor/menubar/PageSelector.tsx +139 -0
  111. package/src/editor/menubar/PageViewerControls.tsx +99 -0
  112. package/src/editor/menubar/Separator.tsx +12 -0
  113. package/src/editor/menubar/SiteInfo.tsx +53 -0
  114. package/src/editor/menubar/User.tsx +27 -0
  115. package/src/editor/menubar/VersionSelector.tsx +143 -0
  116. package/src/editor/page-editor-chrome/CommentHighlighting.tsx +287 -0
  117. package/src/editor/page-editor-chrome/CommentHighlightings.tsx +35 -0
  118. package/src/editor/page-editor-chrome/FieldActionIndicator.tsx +44 -0
  119. package/src/editor/page-editor-chrome/FieldActionIndicators.tsx +23 -0
  120. package/src/editor/page-editor-chrome/FieldEditedIndicator.tsx +64 -0
  121. package/src/editor/page-editor-chrome/FieldEditedIndicators.tsx +35 -0
  122. package/src/editor/page-editor-chrome/FrameMenu.tsx +263 -0
  123. package/src/editor/page-editor-chrome/FrameMenus.tsx +48 -0
  124. package/src/editor/page-editor-chrome/InlineEditor.tsx +147 -0
  125. package/src/editor/page-editor-chrome/LockedFieldIndicator.tsx +61 -0
  126. package/src/editor/page-editor-chrome/NoLayout.tsx +36 -0
  127. package/src/editor/page-editor-chrome/PageEditorChrome.tsx +119 -0
  128. package/src/editor/page-editor-chrome/PictureEditorOverlay.tsx +154 -0
  129. package/src/editor/page-editor-chrome/PlaceholderDropZone.tsx +171 -0
  130. package/src/editor/page-editor-chrome/PlaceholderDropZones.tsx +233 -0
  131. package/src/editor/page-viewer/DeviceToolbar.tsx +70 -0
  132. package/src/editor/page-viewer/EditorForm.tsx +247 -0
  133. package/src/editor/page-viewer/MiniMap.tsx +351 -0
  134. package/src/editor/page-viewer/PageViewer.tsx +127 -0
  135. package/src/editor/page-viewer/PageViewerFrame.tsx +1030 -0
  136. package/src/editor/page-viewer/pageViewContext.ts +186 -0
  137. package/src/editor/pageModel.ts +191 -0
  138. package/src/editor/picture-shared.tsx +53 -0
  139. package/src/editor/reviews/Comment.tsx +265 -0
  140. package/src/editor/reviews/Comments.tsx +50 -0
  141. package/src/editor/reviews/PreviewInfo.tsx +35 -0
  142. package/src/editor/reviews/Reviews.tsx +280 -0
  143. package/src/editor/reviews/reviewCommands.tsx +47 -0
  144. package/src/editor/reviews/useReviews.tsx +70 -0
  145. package/src/editor/services/aiService.ts +155 -0
  146. package/src/editor/services/componentDesignerService.ts +151 -0
  147. package/src/editor/services/contentService.ts +159 -0
  148. package/src/editor/services/editService.ts +462 -0
  149. package/src/editor/services/indexService.ts +24 -0
  150. package/src/editor/services/reviewsService.ts +45 -0
  151. package/src/editor/services/serviceHelper.ts +95 -0
  152. package/src/editor/services/systemService.ts +5 -0
  153. package/src/editor/services/translationService.ts +21 -0
  154. package/src/editor/services-server/api.ts +150 -0
  155. package/src/editor/services-server/graphQL.ts +106 -0
  156. package/src/editor/sidebar/ComponentPalette.tsx +146 -0
  157. package/src/editor/sidebar/ComponentTree.tsx +512 -0
  158. package/src/editor/sidebar/ComponentTree2.tsxx +490 -0
  159. package/src/editor/sidebar/Debug.tsx +105 -0
  160. package/src/editor/sidebar/DictionaryEditor.tsx +261 -0
  161. package/src/editor/sidebar/EditHistory.tsx +134 -0
  162. package/src/editor/sidebar/GraphQL.tsx +164 -0
  163. package/src/editor/sidebar/Insert.tsx +35 -0
  164. package/src/editor/sidebar/MainContentTree.tsx +95 -0
  165. package/src/editor/sidebar/Performance.tsx +53 -0
  166. package/src/editor/sidebar/Sessions.tsx +35 -0
  167. package/src/editor/sidebar/Sidebar.tsx +20 -0
  168. package/src/editor/sidebar/SidebarView.tsx +150 -0
  169. package/src/editor/sidebar/Translations.tsx +276 -0
  170. package/src/editor/sidebar/Validation.tsx +102 -0
  171. package/src/editor/sidebar/ViewSelector.tsx +49 -0
  172. package/src/editor/sidebar/Workbox.tsx +209 -0
  173. package/src/editor/ui/CenteredMessage.tsx +7 -0
  174. package/src/editor/ui/CopyToClipboardButton.tsx +23 -0
  175. package/src/editor/ui/DialogButtons.tsx +11 -0
  176. package/src/editor/ui/Icons.tsx +585 -0
  177. package/src/editor/ui/ItemNameDialog.tsx +94 -0
  178. package/src/editor/ui/ItemNameDialogNew.tsx +118 -0
  179. package/src/editor/ui/ItemSearch.tsx +173 -0
  180. package/src/editor/ui/PerfectTree.tsx +550 -0
  181. package/src/editor/ui/Section.tsx +35 -0
  182. package/src/editor/ui/SimpleIconButton.tsx +43 -0
  183. package/src/editor/ui/SimpleMenu.tsx +48 -0
  184. package/src/editor/ui/SimpleTable.tsx +63 -0
  185. package/src/editor/ui/SimpleTabs.tsx +55 -0
  186. package/src/editor/ui/SimpleToolbar.tsx +7 -0
  187. package/src/editor/ui/Spinner.tsx +7 -0
  188. package/src/editor/ui/Splitter.tsx +247 -0
  189. package/src/editor/ui/StackedPanels.tsx +134 -0
  190. package/src/editor/ui/Toolbar.tsx +7 -0
  191. package/src/editor/utils/id-helper.ts +3 -0
  192. package/src/editor/utils/insertOptions.ts +69 -0
  193. package/src/editor/utils/itemutils.ts +29 -0
  194. package/src/editor/utils/useMemoDebug.ts +28 -0
  195. package/src/editor/utils.ts +435 -0
  196. package/src/editor/views/CompareView.tsx +256 -0
  197. package/src/editor/views/EditView.tsx +27 -0
  198. package/src/editor/views/ItemEditor.tsx +58 -0
  199. package/src/editor/views/SingleEditView.tsx +44 -0
  200. package/src/fonts/Geist-Black.woff2 +0 -0
  201. package/src/fonts/Geist-Bold.woff2 +0 -0
  202. package/src/fonts/Geist-ExtraBold.woff2 +0 -0
  203. package/src/fonts/Geist-ExtraLight.woff2 +0 -0
  204. package/src/fonts/Geist-Light.woff2 +0 -0
  205. package/src/fonts/Geist-Medium.woff2 +0 -0
  206. package/src/fonts/Geist-Regular.woff2 +0 -0
  207. package/src/fonts/Geist-SemiBold.woff2 +0 -0
  208. package/src/fonts/Geist-Thin.woff2 +0 -0
  209. package/src/fonts/Geist[wght].woff2 +0 -0
  210. package/src/index.ts +7 -0
  211. package/src/page-wizard/PageWizard.tsx +163 -0
  212. package/src/page-wizard/SelectWizard.tsx +109 -0
  213. package/src/page-wizard/WizardSteps.tsx +207 -0
  214. package/src/page-wizard/service.ts +35 -0
  215. package/src/page-wizard/startPageWizardCommand.ts +27 -0
  216. package/src/page-wizard/steps/BuildPageStep.tsx +266 -0
  217. package/src/page-wizard/steps/CollectStep.tsx +233 -0
  218. package/src/page-wizard/steps/ComponentTypesSelector.tsx +443 -0
  219. package/src/page-wizard/steps/Components.tsx +193 -0
  220. package/src/page-wizard/steps/CreatePage.tsx +285 -0
  221. package/src/page-wizard/steps/CreatePageAndLayoutStep.tsx +384 -0
  222. package/src/page-wizard/steps/EditButton.tsx +34 -0
  223. package/src/page-wizard/steps/FieldEditor.tsx +102 -0
  224. package/src/page-wizard/steps/Generate.tsx +32 -0
  225. package/src/page-wizard/steps/ImagesStep.tsx +318 -0
  226. package/src/page-wizard/steps/LayoutStep.tsx +228 -0
  227. package/src/page-wizard/steps/SelectStep.tsx +256 -0
  228. package/src/page-wizard/steps/schema.ts +180 -0
  229. package/src/page-wizard/steps/usePageCreator.ts +279 -0
  230. package/src/splash-screen/NewPage.tsx +232 -0
  231. package/src/splash-screen/SectionHeadline.tsx +21 -0
  232. package/src/splash-screen/SplashScreen.tsx +156 -0
  233. package/src/tour/Tour.tsx +558 -0
  234. package/src/tour/default-tour.tsx +300 -0
  235. package/src/tour/preview-tour.tsx +127 -0
  236. package/src/types.ts +302 -0
  237. package/styles.css +476 -0
  238. package/tsconfig.build.json +21 -0
  239. package/tsconfig.json +11 -0
@@ -0,0 +1,505 @@
1
+ import { Dialog } from "primereact/dialog";
2
+ import { useEditContext } from "./client/editContext";
3
+ import { ReactNode, useEffect, useRef, useState } from "react";
4
+ import { Button } from "primereact/button";
5
+ import DialogButtons from "./ui/DialogButtons";
6
+ import { Rect } from "./utils";
7
+ import { classNames } from "primereact/utils";
8
+ import {
9
+ PictureField,
10
+ PictureRawValue,
11
+ PictureValue,
12
+ PictureVariant,
13
+ } from "./fieldTypes";
14
+
15
+ export function PictureCropper({
16
+ field,
17
+ onClose,
18
+ variantName: selectedVariantName,
19
+ }: {
20
+ field: PictureField;
21
+ variantName: string;
22
+ onClose: () => void;
23
+ }) {
24
+ const [pictureValue, setPictureValue] = useState<PictureValue>();
25
+ const [isValid, setIsValid] = useState<boolean>(true);
26
+ const [rawValue, setRawValue] = useState<PictureRawValue>();
27
+
28
+ const imageRef = useRef<HTMLDivElement>(null);
29
+ const [rect, setRect] = useState<Rect>();
30
+ const rectRef = useRef(rect);
31
+ const movingRef = useRef(false);
32
+ const [startPos, setStartPos] = useState<{ x: number; y: number }>({
33
+ x: 0,
34
+ y: 0,
35
+ });
36
+ const offset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
37
+
38
+ const editContext = useEditContext();
39
+
40
+ const EDGE_THRESHOLD = 10; // pixels from edge to detect resize
41
+
42
+ const [resizeEdge, setResizeEdge] = useState<string | null>(null);
43
+ const resizeEdgeRef = useRef<string | null>(null);
44
+
45
+ const getResizeEdge = (
46
+ pos: { x: number; y: number },
47
+ rect?: Rect,
48
+ bounds?: DOMRect
49
+ ) => {
50
+ if (!rect || !bounds) return null;
51
+
52
+ const x = pos.x * bounds.width;
53
+ const y = pos.y * bounds.height;
54
+ const rectX = rect.x * bounds.width;
55
+ const rectY = rect.y * bounds.height;
56
+ const rectWidth = rect.width * bounds.width;
57
+ const rectHeight = rect.height * bounds.height;
58
+
59
+ const isNearLeft = Math.abs(x - rectX) < EDGE_THRESHOLD;
60
+ const isNearRight = Math.abs(x - (rectX + rectWidth)) < EDGE_THRESHOLD;
61
+ const isNearTop = Math.abs(y - rectY) < EDGE_THRESHOLD;
62
+ const isNearBottom = Math.abs(y - (rectY + rectHeight)) < EDGE_THRESHOLD;
63
+
64
+ if (isNearLeft && isNearTop) return "nw";
65
+ if (isNearRight && isNearTop) return "ne";
66
+ if (isNearLeft && isNearBottom) return "sw";
67
+ if (isNearRight && isNearBottom) return "se";
68
+ if (isNearLeft) return "w";
69
+ if (isNearRight) return "e";
70
+ if (isNearTop) return "n";
71
+ if (isNearBottom) return "s";
72
+
73
+ return null;
74
+ };
75
+
76
+ useEffect(() => {
77
+ const deepCopy = JSON.parse(JSON.stringify(field.value));
78
+ setPictureValue(deepCopy);
79
+ const raw = field?.rawValue
80
+ ? (JSON.parse(field?.rawValue) as PictureRawValue)
81
+ : ({ Variants: [] } as PictureRawValue);
82
+ setRawValue(raw);
83
+ }, [field]);
84
+
85
+ const selectedVariant =
86
+ pictureValue && pictureValue.variants
87
+ ? pictureValue.variants?.find(
88
+ (x: PictureVariant) => x.name == selectedVariantName
89
+ )
90
+ : null;
91
+
92
+ useEffect(() => {
93
+ if (
94
+ selectedVariant?.region &&
95
+ (!selectedVariant.aspectRatioLock ||
96
+ Math.abs(
97
+ selectedVariant.aspectRatioLock -
98
+ (selectedVariant.region.width * selectedVariant.width) /
99
+ (selectedVariant.region.height * selectedVariant.height)
100
+ ) < 0.1) &&
101
+ selectedVariant.region.width > 0 &&
102
+ selectedVariant.region.height > 0
103
+ ) {
104
+ setRect({
105
+ width: selectedVariant.region.width,
106
+ height: selectedVariant.region.height,
107
+ y: selectedVariant.region.y,
108
+ x: selectedVariant.region.x,
109
+ });
110
+ } else {
111
+ setRect(undefined);
112
+ }
113
+ }, [selectedVariant]);
114
+
115
+ useEffect(() => {
116
+ if (!rect) return;
117
+ if (
118
+ (selectedVariant?.minWidth && selectedVariant.minWidth > widthPx) ||
119
+ (selectedVariant?.minHeight && selectedVariant?.minHeight > heightPx)
120
+ ) {
121
+ setIsValid(false);
122
+ } else setIsValid(true);
123
+ rectRef.current = rect;
124
+ }, [rect]);
125
+
126
+ useEffect(() => {
127
+ if (!rawValue) return;
128
+ if (!selectedVariantName) return;
129
+ if (selectedVariant) {
130
+ let selected = rawValue.Variants?.find(
131
+ (x) => x.Name == selectedVariantName
132
+ );
133
+ if (!selected) {
134
+ selected = {
135
+ Name: selectedVariantName,
136
+ MediaId: selectedVariant.mediaId!,
137
+ };
138
+ rawValue.Variants?.push(selected);
139
+ }
140
+ if (selected) {
141
+ if (rect)
142
+ selected.Region = {
143
+ X: rect.x,
144
+ Y: rect.y,
145
+ Width: rect.width,
146
+ Height: rect.height,
147
+ };
148
+ else selected.Region = undefined;
149
+ setRawValue(rawValue);
150
+ }
151
+ }
152
+ }, [rect]);
153
+
154
+ const handleMouseMove = (ev: React.MouseEvent) => {
155
+ if (!selectedVariant) return null;
156
+
157
+ const bounds = imageRef.current?.getBoundingClientRect();
158
+ if (!bounds) return;
159
+
160
+ const pos = {
161
+ x: (ev.clientX - bounds.left) / bounds.width,
162
+ y: (ev.clientY - bounds.top) / bounds.height,
163
+ };
164
+
165
+ // Update cursor on hover (when not dragging)
166
+ if (ev.buttons !== 1) {
167
+ const edge = getResizeEdge(pos, rect, bounds);
168
+ setResizeEdge(edge);
169
+ return;
170
+ }
171
+
172
+ if (ev.buttons === 1) {
173
+ if (!rectRef.current || !imageRef.current) return;
174
+ const rect = rectRef.current;
175
+
176
+ const bounds = imageRef.current.getBoundingClientRect();
177
+ const deltaX = ev.clientX - startPos.x;
178
+ const deltaY = ev.clientY - startPos.y;
179
+ if (resizeEdgeRef.current) {
180
+ const pos = {
181
+ x: (ev.clientX - bounds.left) / bounds.width,
182
+ y: (ev.clientY - bounds.top) / bounds.height,
183
+ };
184
+
185
+ let newRect = { ...rect };
186
+ const aspectRatio = selectedVariant.aspectRatioLock;
187
+ const originalAspectRatio =
188
+ selectedVariant.width / selectedVariant.height;
189
+
190
+ if (resizeEdgeRef.current.includes("w")) {
191
+ const newWidth = rect.width + rect.x - pos.x;
192
+ if (newWidth > 0) {
193
+ newRect.width = newWidth;
194
+ newRect.x = pos.x;
195
+ if (aspectRatio) {
196
+ newRect.height =
197
+ (newRect.width / aspectRatio) * originalAspectRatio;
198
+ }
199
+ }
200
+ }
201
+ if (resizeEdgeRef.current.includes("e")) {
202
+ newRect.width = pos.x - rect.x;
203
+ if (aspectRatio) {
204
+ newRect.height =
205
+ (newRect.width / aspectRatio) * originalAspectRatio;
206
+ }
207
+ }
208
+ if (resizeEdgeRef.current.includes("n")) {
209
+ const newHeight = rect.height + rect.y - pos.y;
210
+ if (newHeight > 0) {
211
+ newRect.height = newHeight;
212
+ newRect.y = pos.y;
213
+ if (aspectRatio) {
214
+ newRect.width =
215
+ (newRect.height * aspectRatio) / originalAspectRatio;
216
+ }
217
+ }
218
+ }
219
+ if (resizeEdgeRef.current.includes("s")) {
220
+ newRect.height = pos.y - rect.y;
221
+ if (aspectRatio) {
222
+ newRect.width =
223
+ (newRect.height * aspectRatio) / originalAspectRatio;
224
+ }
225
+ }
226
+
227
+ // Constrain to bounds
228
+ if (newRect.x < 0) newRect.x = 0;
229
+ if (newRect.y < 0) newRect.y = 0;
230
+ if (newRect.x + newRect.width > 1) newRect.width = 1 - newRect.x;
231
+ if (newRect.y + newRect.height > 1) newRect.height = 1 - newRect.y;
232
+
233
+ setRect(newRect);
234
+ } else if (movingRef.current) {
235
+ setRect({
236
+ ...rect,
237
+ x: Math.max(
238
+ 0,
239
+ Math.min(
240
+ 1 - rectRef.current.width,
241
+ (ev.clientX - bounds.left) / bounds.width - offset.current.x
242
+ )
243
+ ),
244
+ y: Math.max(
245
+ 0,
246
+ Math.min(
247
+ 1 - rectRef.current.height,
248
+ (ev.clientY - bounds.top) / bounds.height - offset.current.y
249
+ )
250
+ ),
251
+ });
252
+ } else {
253
+ const originalAspectRatio =
254
+ selectedVariant.width / selectedVariant.height;
255
+
256
+ const aspectRatio = selectedVariant.aspectRatioLock;
257
+
258
+ let width = (ev.clientX - bounds.left) / bounds.width - rect.x;
259
+ let height = (ev.clientY - bounds.top) / bounds.height - rect.y;
260
+
261
+ if (aspectRatio) {
262
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
263
+ height = (width / aspectRatio) * originalAspectRatio;
264
+ } else {
265
+ width = (height * aspectRatio) / originalAspectRatio;
266
+ }
267
+ }
268
+
269
+ if (width + rect.x > 1) {
270
+ width = 1 - rect.x;
271
+ if (aspectRatio) height = (width / aspectRatio) * originalAspectRatio;
272
+ }
273
+
274
+ if (height + rect.y > 1) {
275
+ height = 1 - rect.y;
276
+ if (aspectRatio) width = (height * aspectRatio) / originalAspectRatio;
277
+ }
278
+
279
+ const newRect = { ...rect, width, height };
280
+ setRect(newRect);
281
+ }
282
+ ev.preventDefault();
283
+ ev.stopPropagation();
284
+ }
285
+ };
286
+
287
+ const handleMouseUp = () => {
288
+ if (!rectRef.current || !rectRef.current.width || !rectRef.current.height) {
289
+ setRect(undefined);
290
+ }
291
+ movingRef.current = false;
292
+ resizeEdgeRef.current = null;
293
+ setResizeEdge(null);
294
+ // window.removeEventListener("mousemove", handleMouseMove);
295
+ window.removeEventListener("mouseup", handleMouseUp);
296
+ };
297
+
298
+ const isInRect = (pos: { x: number; y: number }, rect?: Rect) => {
299
+ if (!rect || !selectedVariant) return false;
300
+
301
+ return (
302
+ pos.x >= rect.x &&
303
+ pos.x <= rect.x + rect.width &&
304
+ pos.y >= rect.y &&
305
+ pos.y <= rect.y + rect.height
306
+ );
307
+ };
308
+
309
+ const handleMouseDown = (ev: React.MouseEvent) => {
310
+ ev.preventDefault();
311
+ ev.stopPropagation();
312
+ const bounds = ev.currentTarget.getBoundingClientRect();
313
+ const pos = {
314
+ x: (ev.clientX - bounds.left) / bounds.width,
315
+ y: (ev.clientY - bounds.top) / bounds.height,
316
+ };
317
+
318
+ const edge = getResizeEdge(pos, rect, bounds);
319
+ if (edge) {
320
+ resizeEdgeRef.current = edge;
321
+ setResizeEdge(edge);
322
+ } else if (isInRect(pos, rect)) {
323
+ movingRef.current = true;
324
+ offset.current = {
325
+ x: pos.x - rect!.x,
326
+ y: pos.y - rect!.y,
327
+ };
328
+ } else {
329
+ setRect({
330
+ x: (ev.clientX - bounds.left) / bounds.width,
331
+ y: (ev.clientY - bounds.top) / bounds.height,
332
+ width: 0,
333
+ height: 0,
334
+ });
335
+ }
336
+
337
+ setStartPos({ x: ev.clientX, y: ev.clientY });
338
+
339
+ window.addEventListener("mouseup", handleMouseUp);
340
+ };
341
+
342
+ if (!selectedVariant) return null;
343
+
344
+ const imageBounds = imageRef.current?.getBoundingClientRect();
345
+ const scale = imageBounds ? selectedVariant.width / imageBounds.width : 0;
346
+ const widthPx = rect ? Math.round(rect.width * selectedVariant.width) : 0;
347
+ const heightPx = rect ? Math.round(rect.height * selectedVariant.height) : 0;
348
+
349
+ return (
350
+ <>
351
+ <Dialog
352
+ header={"Crop " + field.name + " - " + selectedVariantName}
353
+ pt={{ content: { style: { paddingLeft: "0" } } }}
354
+ visible={true}
355
+ style={{ width: "75vw", height: "75vh" }}
356
+ onHide={onClose}
357
+ >
358
+ <div className="flex gap-1 flex-col justify h-full">
359
+ <div className="flex-1 flex gap-2">
360
+ <div className="w-56 text-sm p-4 bg-gray-100 flex flex-col gap-3">
361
+ <LabelAndValue label="Variant:" value={selectedVariantName} />
362
+ <LabelAndValue
363
+ label="Image Dimensions:"
364
+ value={
365
+ <>
366
+ {selectedVariant.width} x {selectedVariant.height}
367
+ </>
368
+ }
369
+ />
370
+ {selectedVariant.aspectRatioLock && (
371
+ <LabelAndValue
372
+ label="Required Aspect Ratio:"
373
+ value={selectedVariant.aspectRatioLockText}
374
+ />
375
+ )}
376
+ {selectedVariant.minWidth && (
377
+ <LabelAndValue
378
+ label="Minimum Width:"
379
+ value={selectedVariant.minWidth}
380
+ />
381
+ )}
382
+ {selectedVariant.minHeight && (
383
+ <LabelAndValue
384
+ label="Minimum Height:"
385
+ value={selectedVariant.minHeight}
386
+ />
387
+ )}
388
+ {rect && (
389
+ <>
390
+ <LabelAndValue
391
+ label="Selection:"
392
+ value={
393
+ <>
394
+ {widthPx} x {heightPx}
395
+ </>
396
+ }
397
+ />
398
+ {selectedVariant.minWidth &&
399
+ widthPx < selectedVariant.minWidth && (
400
+ <div className="text-red-500">
401
+ Minimum width not met!
402
+ </div>
403
+ )}
404
+ {selectedVariant.minHeight &&
405
+ heightPx < selectedVariant.minHeight && (
406
+ <div className="text-red-500">
407
+ Minimum height not met!
408
+ </div>
409
+ )}
410
+ </>
411
+ )}
412
+ </div>
413
+ <div className="flex-1 relative p-3">
414
+ <div className="absolute inset-0 top-3 select-none flex items-center justify-center">
415
+ <div
416
+ ref={imageRef}
417
+ className="relative cursor-crosshair max-h-full"
418
+ style={{
419
+ aspectRatio: `${selectedVariant.width}/${selectedVariant.height}`,
420
+ }}
421
+ onMouseDown={handleMouseDown}
422
+ onMouseMove={handleMouseMove}
423
+ >
424
+ <img
425
+ className="object-scale-down"
426
+ src={selectedVariant.originalSrc ?? selectedVariant.src}
427
+ />
428
+ {rect && (
429
+ <div
430
+ className={classNames(
431
+ "absolute bg-opacity-70 border text-xs cursor-move",
432
+ isValid
433
+ ? "bg-blue-200 border-blue-400 text-blue-500"
434
+ : "bg-red-200 border-red-400 text-red-500"
435
+ )}
436
+ style={{
437
+ left: rect.x * 100 + "%",
438
+ top: rect.y * 100 + "%",
439
+ width: widthPx / scale + "px",
440
+ height: heightPx / scale + "px",
441
+ cursor: resizeEdge
442
+ ? resizeEdge.length === 1
443
+ ? `${resizeEdge}-resize`
444
+ : `${resizeEdge}-resize`
445
+ : "move",
446
+ }}
447
+ >
448
+ {widthPx / scale > 50 && (
449
+ <div
450
+ className="absolute bottom-1 right-2 text-nowrap"
451
+ style={{ textShadow: "white 1px 1px" }}
452
+ >
453
+ {widthPx} x {heightPx}
454
+ </div>
455
+ )}
456
+ </div>
457
+ )}
458
+ </div>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ <DialogButtons>
463
+ <Button onClick={() => setRect(undefined)}>Reset</Button>
464
+ <Button
465
+ size="small"
466
+ disabled={!isValid}
467
+ onClick={() => {
468
+ if (pictureValue) {
469
+ if (field) {
470
+ editContext?.operations.editField({
471
+ field: field.descriptor,
472
+ rawValue: JSON.stringify(rawValue),
473
+ refresh: "immediate",
474
+ });
475
+ }
476
+ }
477
+ onClose();
478
+ }}
479
+ >
480
+ Ok
481
+ </Button>
482
+ <Button onClick={onClose} size="small">
483
+ Cancel
484
+ </Button>
485
+ </DialogButtons>
486
+ </div>
487
+ </Dialog>
488
+ </>
489
+ );
490
+ }
491
+
492
+ function LabelAndValue({
493
+ label,
494
+ value,
495
+ }: {
496
+ label: ReactNode;
497
+ value: ReactNode;
498
+ }) {
499
+ return (
500
+ <div>
501
+ <div className="font-bold">{label}</div>
502
+ <div>{value}</div>
503
+ </div>
504
+ );
505
+ }
@@ -0,0 +1,206 @@
1
+ import { MouseEventHandler, useCallback, useState } from "react";
2
+ import { useEditContext } from "./client/editContext";
3
+ import { PictureCropper } from "./PictureCropper";
4
+ import { PictureField, PictureRawValue } from "./fieldTypes";
5
+ import { MediaSelectorMode } from "./media-selector/MediaSelector";
6
+
7
+ export function PictureEditor({
8
+ field,
9
+ variantName,
10
+ style,
11
+ forwardScrollevents,
12
+ isPageEditor,
13
+ }: {
14
+ field: PictureField;
15
+ variantName: string;
16
+ style?: React.CSSProperties;
17
+ forwardScrollevents?: boolean;
18
+ isPageEditor?: boolean;
19
+ }) {
20
+ const [showMenu, setShowMenu] = useState(false);
21
+ const [showCropper, setShowCropper] = useState(false);
22
+ const editContext = useEditContext();
23
+
24
+ const variant = field.value?.variants?.find((v) => v.name === variantName);
25
+ const raw =
26
+ field?.rawValue && field?.rawValue[0] === "{"
27
+ ? (JSON.parse(field?.rawValue) as PictureRawValue)
28
+ : ({ Variants: [] } as PictureRawValue);
29
+
30
+ const rawVariant = raw.Variants?.find((x) => x.Name == variantName);
31
+
32
+ const reset = useCallback(() => {
33
+ raw.Variants = raw.Variants.filter((x) => x.Name !== variantName);
34
+ editContext!.operations.editField({
35
+ field: field.descriptor,
36
+ rawValue: JSON.stringify(raw),
37
+ refresh: "immediate",
38
+ });
39
+ }, [field, variant]);
40
+
41
+ const imageSelected = useCallback(
42
+ async (imageId: string) => {
43
+ const selected = raw.Variants?.find((x) => x.Name == variantName);
44
+ if (!selected) {
45
+ if (!raw.Variants) raw.Variants = [];
46
+ raw.Variants.push({
47
+ Name: variantName,
48
+ MediaId: imageId,
49
+ });
50
+ } else {
51
+ selected.MediaId = imageId;
52
+ }
53
+
54
+ editContext!.operations.editField({
55
+ field: field.descriptor,
56
+ rawValue: JSON.stringify(raw),
57
+ refresh: "immediate",
58
+ });
59
+ },
60
+ [field, variant]
61
+ );
62
+
63
+ const videoSelected = useCallback(
64
+ async (videoId: string) => {
65
+ const selected = raw.Variants?.find((x) => x.Name == variantName);
66
+ if (!selected) {
67
+ if (!raw.Variants) raw.Variants = [];
68
+ raw.Variants.push({
69
+ Name: variantName,
70
+ VideoId: videoId,
71
+ });
72
+ } else {
73
+ selected.VideoId = videoId;
74
+ }
75
+
76
+ editContext!.operations.editField({
77
+ field: field.descriptor,
78
+ rawValue: JSON.stringify(raw),
79
+ refresh: "immediate",
80
+ });
81
+ },
82
+ [field, variant]
83
+ );
84
+
85
+ async function selectMedia(mode: MediaSelectorMode) {
86
+ const selectedImageId = await editContext?.selectMedia({
87
+ selectedIdPath: variant?.idPath || "",
88
+ mode: mode,
89
+ });
90
+ if (selectedImageId) imageSelected(selectedImageId);
91
+ }
92
+
93
+ async function selectVideo() {
94
+ const selectedVideoId = await editContext?.selectMedia({
95
+ selectedIdPath: variant?.idPath || "",
96
+ mode: "video",
97
+ });
98
+ if (selectedVideoId) videoSelected(selectedVideoId);
99
+ }
100
+
101
+ const notEmpty = variant?.mediaId;
102
+
103
+ return (
104
+ <div
105
+ className="absolute inset-0 flex items-center justify-center"
106
+ onMouseEnter={() => setShowMenu(true)}
107
+ onMouseLeave={() => setShowMenu(false)}
108
+ onClick={() => {
109
+ if (!isPageEditor) return;
110
+ const itemId = field.descriptor.item?.id;
111
+ if (itemId) editContext?.select([itemId]);
112
+ }}
113
+ onWheel={(e) => {
114
+ if (forwardScrollevents)
115
+ editContext?.pageView.editorIframeRef!.current?.contentWindow?.document.documentElement?.scrollBy(
116
+ {
117
+ behavior: "instant",
118
+ left: 0,
119
+ top: e.deltaY,
120
+ }
121
+ );
122
+ editContext?.pageView.editorIframeRef!.current?.contentWindow?.document.body?.scrollBy(
123
+ {
124
+ behavior: "instant",
125
+ left: 0,
126
+ top: e.deltaY,
127
+ }
128
+ );
129
+ }}
130
+ style={style}
131
+ data-testid="select-media"
132
+ >
133
+ {showMenu && (
134
+ <div className="p-3 grid grid-cols-2 gap-1.5 items-stretch justify-center text-sm min-w-48">
135
+ <Btn
136
+ label="Select"
137
+ icon="pi pi-image"
138
+ onClick={() => selectMedia("images")}
139
+ testId="select-media-button"
140
+ className="min-w-[80px]"
141
+ />
142
+ {field.value?.allowVideos && (
143
+ <Btn
144
+ label="Video"
145
+ icon="pi pi-video"
146
+ onClick={() => selectVideo()}
147
+ testId="video-media-button"
148
+ className="min-w-[80px]"
149
+ />
150
+ )}
151
+ {notEmpty && (
152
+ <Btn
153
+ label="Crop"
154
+ icon="pi pi-expand"
155
+ onClick={() => {
156
+ setShowCropper(true);
157
+ }}
158
+ testId="crop-media-button"
159
+ className="min-w-[80px]"
160
+ />
161
+ )}
162
+ {rawVariant && (
163
+ <Btn
164
+ label="Reset"
165
+ icon="pi pi-times"
166
+ onClick={() => reset()}
167
+ testId="reset-media-button"
168
+ className="min-w-[80px]"
169
+ />
170
+ )}
171
+ </div>
172
+ )}
173
+ {showCropper && (
174
+ <PictureCropper
175
+ field={field}
176
+ onClose={() => setShowCropper(false)}
177
+ variantName={variantName}
178
+ />
179
+ )}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ function Btn({
185
+ label,
186
+ icon,
187
+ onClick,
188
+ testId,
189
+ className,
190
+ }: {
191
+ label: string;
192
+ icon: string;
193
+ onClick: MouseEventHandler<HTMLButtonElement>;
194
+ testId?: string;
195
+ className?: string;
196
+ }) {
197
+ return (
198
+ <button
199
+ className={`btn bg-gray-500 text-white p-1.5 rounded-lg opacity-70 border hover:opacity-85 gap-1.5 flex items-center cursor-pointer ${className}`}
200
+ onClick={onClick}
201
+ data-testid={testId}
202
+ >
203
+ <i className={icon} /> {label}
204
+ </button>
205
+ );
206
+ }