@overmap-ai/forms 0.0.1-master.3

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 (130) hide show
  1. package/.husky/pre-commit +6 -0
  2. package/.prettierrc.json +10 -0
  3. package/.storybook/StoryDecorator.tsx +22 -0
  4. package/.storybook/main.ts +20 -0
  5. package/.storybook/palettes/green.css +66 -0
  6. package/.storybook/palettes/red.css +66 -0
  7. package/.storybook/preview.css +39 -0
  8. package/.storybook/preview.tsx +31 -0
  9. package/.storybook/tailwind-theme/accentPalette.css +181 -0
  10. package/.storybook/tailwind-theme/backgrounds.css +11 -0
  11. package/.storybook/tailwind-theme/basePalette.css +178 -0
  12. package/dev/publish-alpha.sh +13 -0
  13. package/dev/publish-patch.sh +3 -0
  14. package/eslint.config.js +56 -0
  15. package/package.json +93 -0
  16. package/src/ColorPicker/ColorPicker.tsx +47 -0
  17. package/src/ColorPicker/index.ts +1 -0
  18. package/src/FileBadge/FileBadge.tsx +27 -0
  19. package/src/FileBadge/index.ts +1 -0
  20. package/src/FileCard/FileCard.stories.tsx +69 -0
  21. package/src/FileCard/FileCard.tsx +53 -0
  22. package/src/FileCard/index.ts +1 -0
  23. package/src/FileIcon/FileIcon.tsx +31 -0
  24. package/src/FileIcon/index.ts +1 -0
  25. package/src/FileViewer/FileViewerProvider.stories.tsx +50 -0
  26. package/src/FileViewer/FileViewerProvider.tsx +72 -0
  27. package/src/FileViewer/context.ts +11 -0
  28. package/src/FileViewer/index.ts +3 -0
  29. package/src/FileViewer/typings.ts +5 -0
  30. package/src/ImageCard/ImageCard.stories.tsx +94 -0
  31. package/src/ImageCard/ImageCard.tsx +82 -0
  32. package/src/ImageCard/index.ts +1 -0
  33. package/src/ImageMarkup/ImageMarkup.stories.tsx +65 -0
  34. package/src/ImageMarkup/ImageMarkup.tsx +268 -0
  35. package/src/ImageMarkup/index.ts +1 -0
  36. package/src/ImageViewer/ImageViewer.stories.tsx +57 -0
  37. package/src/ImageViewer/ImageViewer.tsx +124 -0
  38. package/src/ImageViewer/constants.ts +1 -0
  39. package/src/ImageViewer/index.ts +2 -0
  40. package/src/PDFViewer/PDFViewer.stories.tsx +55 -0
  41. package/src/PDFViewer/PDFViewer.tsx +170 -0
  42. package/src/PDFViewer/constants.ts +1 -0
  43. package/src/PDFViewer/index.ts +2 -0
  44. package/src/SpreadsheetViewer/SpreadsheetViewer.stories.tsx +55 -0
  45. package/src/SpreadsheetViewer/SpreadsheetViewer.tsx +162 -0
  46. package/src/SpreadsheetViewer/constants.ts +8 -0
  47. package/src/SpreadsheetViewer/index.ts +2 -0
  48. package/src/forms/builder/DropDispatch.ts +84 -0
  49. package/src/forms/builder/FieldActions.tsx +155 -0
  50. package/src/forms/builder/FieldBuilder.tsx +386 -0
  51. package/src/forms/builder/FieldSectionWithActions.tsx +260 -0
  52. package/src/forms/builder/FieldWithActions.tsx +129 -0
  53. package/src/forms/builder/FieldsEditor.tsx +180 -0
  54. package/src/forms/builder/FormBuilder.stories.tsx +105 -0
  55. package/src/forms/builder/FormBuilder.tsx +237 -0
  56. package/src/forms/builder/constants.ts +18 -0
  57. package/src/forms/builder/hooks.tsx +24 -0
  58. package/src/forms/builder/index.ts +2 -0
  59. package/src/forms/builder/typings.ts +18 -0
  60. package/src/forms/builder/utils.ts +229 -0
  61. package/src/forms/constants.ts +9 -0
  62. package/src/forms/constantsJsx.tsx +67 -0
  63. package/src/forms/fields/BaseField/BaseField.ts +152 -0
  64. package/src/forms/fields/BaseField/hooks.tsx +60 -0
  65. package/src/forms/fields/BaseField/index.ts +4 -0
  66. package/src/forms/fields/BaseField/layouts.tsx +100 -0
  67. package/src/forms/fields/BaseField/typings.ts +9 -0
  68. package/src/forms/fields/BooleanField/BooleanField.tsx +48 -0
  69. package/src/forms/fields/BooleanField/BooleanInput.tsx +54 -0
  70. package/src/forms/fields/BooleanField/index.ts +2 -0
  71. package/src/forms/fields/CustomField/CustomField.tsx +45 -0
  72. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputCloner.tsx +25 -0
  73. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputClonerField.tsx +26 -0
  74. package/src/forms/fields/CustomField/FieldInputClonerField/index.ts +3 -0
  75. package/src/forms/fields/CustomField/FieldInputClonerField/typings.ts +8 -0
  76. package/src/forms/fields/CustomField/index.ts +1 -0
  77. package/src/forms/fields/DateField/DateField.tsx +42 -0
  78. package/src/forms/fields/DateField/DateInput.tsx +39 -0
  79. package/src/forms/fields/DateField/index.ts +2 -0
  80. package/src/forms/fields/FieldSection/FieldSection.tsx +173 -0
  81. package/src/forms/fields/FieldSection/FieldSectionLayout.tsx +56 -0
  82. package/src/forms/fields/FieldSection/index.ts +1 -0
  83. package/src/forms/fields/MultiStringField/MultiStringField.tsx +90 -0
  84. package/src/forms/fields/MultiStringField/MultiStringInput.tsx +207 -0
  85. package/src/forms/fields/MultiStringField/index.ts +2 -0
  86. package/src/forms/fields/NumberField/NumberField.tsx +173 -0
  87. package/src/forms/fields/NumberField/NumberInput.tsx +44 -0
  88. package/src/forms/fields/NumberField/index.ts +2 -0
  89. package/src/forms/fields/QrField/QrField.tsx +38 -0
  90. package/src/forms/fields/QrField/QrInput.module.sass +5 -0
  91. package/src/forms/fields/QrField/QrInput.tsx +144 -0
  92. package/src/forms/fields/QrField/index.ts +2 -0
  93. package/src/forms/fields/SelectField/BaseSelectField.ts +73 -0
  94. package/src/forms/fields/SelectField/MultiSelectField.tsx +53 -0
  95. package/src/forms/fields/SelectField/MultiSelectInput.tsx +80 -0
  96. package/src/forms/fields/SelectField/SelectField.tsx +49 -0
  97. package/src/forms/fields/SelectField/SelectInput.tsx +69 -0
  98. package/src/forms/fields/SelectField/index.ts +4 -0
  99. package/src/forms/fields/StringOrTextFields/StringField/StringField.tsx +61 -0
  100. package/src/forms/fields/StringOrTextFields/StringField/StringInput.tsx +41 -0
  101. package/src/forms/fields/StringOrTextFields/StringField/index.ts +2 -0
  102. package/src/forms/fields/StringOrTextFields/StringOrTextField.ts +143 -0
  103. package/src/forms/fields/StringOrTextFields/TextField/TextField.tsx +52 -0
  104. package/src/forms/fields/StringOrTextFields/TextField/TextInput.tsx +42 -0
  105. package/src/forms/fields/StringOrTextFields/TextField/index.ts +2 -0
  106. package/src/forms/fields/StringOrTextFields/index.ts +2 -0
  107. package/src/forms/fields/UploadField/UploadField.tsx +156 -0
  108. package/src/forms/fields/UploadField/UploadInput.tsx +220 -0
  109. package/src/forms/fields/UploadField/index.ts +2 -0
  110. package/src/forms/fields/UploadField/utils.ts +17 -0
  111. package/src/forms/fields/constants.ts +43 -0
  112. package/src/forms/fields/hooks.tsx +26 -0
  113. package/src/forms/fields/index.ts +12 -0
  114. package/src/forms/fields/typings.ts +45 -0
  115. package/src/forms/fields/utils.ts +125 -0
  116. package/src/forms/index.ts +5 -0
  117. package/src/forms/renderer/FormRenderer/FormRenderer.stories.tsx +142 -0
  118. package/src/forms/renderer/FormRenderer/FormRenderer.tsx +135 -0
  119. package/src/forms/renderer/PatchForm/Field.tsx +41 -0
  120. package/src/forms/renderer/PatchForm/PatchForm.stories.tsx +91 -0
  121. package/src/forms/renderer/PatchForm/Provider.tsx +119 -0
  122. package/src/forms/renderer/PatchForm/index.ts +2 -0
  123. package/src/forms/renderer/index.ts +2 -0
  124. package/src/forms/typings.ts +162 -0
  125. package/src/forms/utils.ts +69 -0
  126. package/src/index.ts +11 -0
  127. package/src/vite-env.d.ts +1 -0
  128. package/tailwind.config.ts +8 -0
  129. package/tsconfig.json +26 -0
  130. package/vite.config.ts +23 -0
@@ -0,0 +1,82 @@
1
+ import { RiIcon, Spinner, useSize } from "@overmap-ai/blocks"
2
+ import { truncate } from "@overmap-ai/core"
3
+ import { cx } from "class-variance-authority"
4
+ import {
5
+ ComponentProps,
6
+ forwardRef,
7
+ memo,
8
+ PropsWithoutRef,
9
+ ReactNode,
10
+ RefObject,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useRef,
14
+ } from "react"
15
+
16
+ import { FileIcon } from "../FileIcon"
17
+
18
+ export interface ImageCardProps extends Omit<PropsWithoutRef<ComponentProps<"div">>, "children"> {
19
+ file: File | null
20
+ alt?: string
21
+ error?: string
22
+ truncateLength?: number
23
+ rightSlot?: ReactNode
24
+ }
25
+ export const ImageCard = memo(
26
+ forwardRef<HTMLDivElement, ImageCardProps>((props, forwardedRef) => {
27
+ const { file, alt, error, rightSlot, className, truncateLength, ...rest } = props
28
+ const fileCardRef = useRef<HTMLDivElement>(null)
29
+ const imageInsetRef = useRef<HTMLDivElement>(null)
30
+ const fileCardSize = useSize(fileCardRef as RefObject<HTMLDivElement>)
31
+
32
+ useLayoutEffect(() => {
33
+ if (!imageInsetRef.current || !fileCardSize) return
34
+ imageInsetRef.current.style.height = `${fileCardSize.height * 4}px`
35
+ }, [fileCardSize])
36
+
37
+ const fileName = useMemo(() => {
38
+ if (!file) return
39
+ return truncateLength !== undefined ? truncate(file.name, truncateLength) : file.name
40
+ }, [file, truncateLength])
41
+
42
+ return (
43
+ <div
44
+ className={cx(
45
+ className,
46
+ "relative flex h-max w-full flex-col gap-0 overflow-hidden rounded-md border items-center border-(--base-a6)",
47
+ )}
48
+ ref={forwardedRef}
49
+ {...rest}
50
+ >
51
+ {!file && !error && (
52
+ <div className="absolute flex h-full w-full flex-col items-center justify-center">
53
+ <Spinner />
54
+ </div>
55
+ )}
56
+
57
+ <div
58
+ ref={imageInsetRef}
59
+ className="-m-4 flex max-w-full items-center justify-center overflow-hidden bg-clip-padding"
60
+ >
61
+ {file && !error && (
62
+ <img
63
+ className="max-w-full object-cover"
64
+ src={URL.createObjectURL(file)}
65
+ alt={alt ?? file.name}
66
+ />
67
+ )}
68
+ </div>
69
+ <div
70
+ className={cx("flex h-max w-full items-center gap-1 bg-(--base-2) px-2 py-1", {
71
+ "bg-transparent": !file,
72
+ })}
73
+ ref={fileCardRef}
74
+ >
75
+ {error ? <RiIcon icon="RiFileWarningLine" /> : file && <FileIcon fileType={file.type} />}
76
+ {error ?? fileName}
77
+ {rightSlot}
78
+ </div>
79
+ </div>
80
+ )
81
+ }),
82
+ )
@@ -0,0 +1 @@
1
+ export * from "./ImageCard"
@@ -0,0 +1,65 @@
1
+ import { Button } from "@overmap-ai/blocks"
2
+ import type { Meta } from "@storybook/react"
3
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
4
+
5
+ import { ImageMarkup } from "./ImageMarkup"
6
+
7
+ const meta = {
8
+ title: "Components/ImageMarkup",
9
+ component: ImageMarkup,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ disablePanel: true,
13
+ },
14
+ } satisfies Meta<typeof ImageMarkup>
15
+
16
+ export default meta
17
+
18
+ export const Basic = () => {
19
+ const inputRef = useRef<HTMLInputElement>(null)
20
+ const [file, setFile] = useState<File | null>(null)
21
+ const [open, setOpen] = useState(false)
22
+ const [dirty, setDirty] = useState(false)
23
+
24
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
25
+ if (e.target.files) {
26
+ const files = Array.from(e.target.files)
27
+ if (files.length > 0) {
28
+ setFile(files[0] || null)
29
+ }
30
+ }
31
+ }, [])
32
+
33
+ return (
34
+ <>
35
+ <input ref={inputRef} type="file" onChange={handleChange} />
36
+ <Button
37
+ onClick={() => {
38
+ setOpen(!open)
39
+ }}
40
+ >
41
+ Open
42
+ </Button>
43
+ {file && open && (
44
+ <ImageMarkup
45
+ dirty={dirty}
46
+ onDirty={(dirty) => {
47
+ setDirty(dirty)
48
+ }}
49
+ file={file}
50
+ onClose={() => {
51
+ setOpen(false)
52
+ }}
53
+ onDelete={() => {
54
+ setFile(null)
55
+ alert("deleted")
56
+ }}
57
+ onSave={(file: File) => {
58
+ setFile(file)
59
+ alert("saved")
60
+ }}
61
+ />
62
+ )}
63
+ </>
64
+ )
65
+ }
@@ -0,0 +1,268 @@
1
+ import { ButtonGroup, IconButton, IconToggleButton, RiIcon, Separator, Spinner, useSize } from "@overmap-ai/blocks"
2
+ import { CSSColor } from "@overmap-ai/core"
3
+ import { Colors, DeferredPromise, downloadFile, fileToBlob, hashFile } from "@overmap-ai/core"
4
+ import * as RadixDialog from "@radix-ui/react-dialog"
5
+ import React, { memo, RefObject, useCallback, useEffect, useRef, useState } from "react"
6
+ import { ReactSketchCanvas, ReactSketchCanvasRef } from "react-sketch-canvas"
7
+
8
+ import { ColorPicker } from "../ColorPicker"
9
+
10
+ type FileWithObjectURL = File & { objectURL?: string }
11
+ export interface AttachmentMarkupProps {
12
+ file: FileWithObjectURL
13
+ onClose: () => void
14
+ onSave: (imageWithMarkup: FileWithObjectURL) => void
15
+ onDelete: (imageFile: FileWithObjectURL) => void
16
+ dirty: boolean
17
+ onDirty: (dirty: boolean) => void
18
+ }
19
+
20
+ const colors: CSSColor[] = Object.values(Colors)
21
+
22
+ // 0-1 range, 1 is best quality.
23
+ const COMPRESSION_QUALITY = 1
24
+
25
+ async function combineImages(
26
+ canvas: HTMLCanvasElement,
27
+ image1: File,
28
+ image2: File,
29
+ finalSize: { width: number; height: number },
30
+ ): Promise<FileWithObjectURL> {
31
+ const context = canvas.getContext("2d", {} satisfies CanvasRenderingContext2DSettings)!
32
+ const imageObj1 = new Image(finalSize.width, finalSize.height)
33
+ const imageObj2 = new Image(finalSize.width, finalSize.height)
34
+ const ret = new DeferredPromise<FileWithObjectURL>()
35
+ // Add an onload handler to the image
36
+ imageObj1.onload = function () {
37
+ context.drawImage(imageObj1, 0, 0, finalSize.width, finalSize.height)
38
+ imageObj2.src = URL.createObjectURL(image2)
39
+ imageObj2.onload = async function () {
40
+ context.drawImage(imageObj2, 0, 0, finalSize.width, finalSize.height)
41
+ const combinedDataUrl = canvas.toDataURL("image/jpeg", COMPRESSION_QUALITY)
42
+ const asBlob = await fileToBlob(combinedDataUrl)
43
+ const asFile = new File([asBlob], image1.name, { type: "image/jpeg" })
44
+ ret.resolve(asFile as FileWithObjectURL)
45
+ }
46
+ }
47
+ // Load the image
48
+ imageObj1.src = URL.createObjectURL(image1)
49
+ return ret
50
+ }
51
+
52
+ // TODO: Make this similar to the Alert Dialog setup with a context
53
+ export const ImageMarkup = memo((props: AttachmentMarkupProps) => {
54
+ const { file, onClose, onSave, onDelete, dirty, onDirty } = props
55
+ if (!file.objectURL) {
56
+ file.objectURL = URL.createObjectURL(file)
57
+ }
58
+ const canvasRef = useRef<ReactSketchCanvasRef>(null)
59
+ const imageCombinerRef = useRef<HTMLCanvasElement>(null)
60
+ const [color, setColor] = useState<CSSColor>(Colors.red!)
61
+ const [originalImageSize, setOriginalImageSize] = useState<{ width: number; height: number } | null>(null)
62
+ const [loading, setLoading] = useState(false)
63
+ const [reset, setReset] = useState(false)
64
+
65
+ // We'll be watching for resize events of the inner container, so we know what to animate the height to.
66
+ const watchResizeTarget = useRef<HTMLImageElement>(null)
67
+ const currentImageSize = useSize(watchResizeTarget as RefObject<HTMLImageElement>)
68
+
69
+ useEffect(() => {
70
+ const image = new Image()
71
+ image.onload = function () {
72
+ setOriginalImageSize({ width: image.naturalWidth, height: image.naturalHeight })
73
+ }
74
+ image.src = URL.createObjectURL(file)
75
+ }, [file])
76
+
77
+ useEffect(() => {
78
+ if (!reset && currentImageSize?.width && currentImageSize.height) {
79
+ setReset(true)
80
+ }
81
+ }, [currentImageSize, reset])
82
+
83
+ const saveMarkup = useCallback(async () => {
84
+ if (!dirty) onClose()
85
+
86
+ const currentCanvasRef = canvasRef.current
87
+ if (!currentCanvasRef) return
88
+
89
+ if (!originalImageSize) {
90
+ throw new Error("Original image size not loaded yet")
91
+ }
92
+
93
+ const markupDataUrl: string = await currentCanvasRef.exportImage("png")
94
+ const markupImage = new Image(originalImageSize.width, originalImageSize.height)
95
+
96
+ markupImage.onload = async function () {
97
+ const markupBlob: Blob = await fileToBlob(markupDataUrl)
98
+ const markupFile: File = new File([markupBlob], file.name, { type: file.type })
99
+ const canvas = imageCombinerRef.current!
100
+ const combinedFile = await combineImages(canvas, file, markupFile, originalImageSize)
101
+ combinedFile.objectURL = URL.createObjectURL(combinedFile)
102
+ const oldHash = await hashFile(file)
103
+ const newHash = await hashFile(combinedFile)
104
+ // If the markup didn't change the image, we don't need to save it. In fact, doing so results in an error.
105
+ if (newHash !== oldHash) {
106
+ onSave(combinedFile)
107
+ }
108
+ setLoading(false)
109
+ onClose()
110
+ }
111
+
112
+ setLoading(true)
113
+ // Initiates the loading of image process
114
+ markupImage.src = markupDataUrl
115
+ }, [dirty, file, onClose, onSave, originalImageSize])
116
+
117
+ const handleDelete = useCallback(() => {
118
+ onDelete(file)
119
+ }, [file, onDelete])
120
+
121
+ const handleCancel = useCallback(() => {
122
+ onClose()
123
+ }, [onClose])
124
+
125
+ const handleCanvasChange = useCallback(() => {
126
+ onDirty(true)
127
+ }, [onDirty])
128
+
129
+ const handleRedoLast = useCallback(() => {
130
+ if (!canvasRef.current) return
131
+ canvasRef.current.redo()
132
+ }, [])
133
+
134
+ const handleUndoLast = useCallback(() => {
135
+ if (!canvasRef.current) return
136
+ canvasRef.current.undo()
137
+ }, [])
138
+
139
+ const handleUndoAll = useCallback(() => {
140
+ if (!canvasRef.current) return
141
+ onDirty(false)
142
+ canvasRef.current.clearCanvas()
143
+ }, [onDirty])
144
+
145
+ const handleToggleEraseMode = useCallback((erasing: boolean) => {
146
+ if (!canvasRef.current) return
147
+ canvasRef.current.eraseMode(erasing)
148
+ }, [])
149
+
150
+ const handleSave = useCallback(() => {
151
+ void saveMarkup()
152
+ }, [saveMarkup])
153
+
154
+ const handleDownload: React.MouseEventHandler<HTMLButtonElement> = useCallback(() => {
155
+ downloadFile(file)
156
+ }, [file])
157
+
158
+ return (
159
+ <RadixDialog.Root open onOpenChange={onClose}>
160
+ <RadixDialog.Portal>
161
+ <RadixDialog.Overlay className="light:bg-(--black-a6) fixed inset-0 dark:bg-(--black-a8)" />
162
+ <RadixDialog.Content className="fixed inset-0">
163
+ <>
164
+ {/* Invisible canvas for combining background image and markup */}
165
+ {originalImageSize && (
166
+ <canvas
167
+ style={{ display: "none" }}
168
+ id="attachment-markup-canvas"
169
+ ref={imageCombinerRef}
170
+ width={originalImageSize.width}
171
+ height={originalImageSize.height}
172
+ />
173
+ )}
174
+ <div className="relative flex h-full w-full flex-col">
175
+ <div className="flex h-8 w-full items-center bg-(--color-background) px-2">
176
+ <ButtonGroup accentColor="base" size="md" variant="soft">
177
+ <div className="grid w-full grid-cols-3">
178
+ <div className="flex gap-2">
179
+ <IconButton aria-label="close" onClick={handleCancel}>
180
+ <RiIcon icon="RiCloseLine" />
181
+ </IconButton>
182
+ <IconButton aria-label={`Download ${file.name}`} onClick={handleDownload}>
183
+ <RiIcon icon="RiDownload2Line" />
184
+ </IconButton>
185
+ </div>
186
+ <div className="flex items-center justify-center gap-2">
187
+ <ColorPicker
188
+ selectedColor={color}
189
+ allColors={colors}
190
+ onFinish={setColor}
191
+ trigger={
192
+ <IconButton
193
+ aria-label="Markup color picker"
194
+ type="button"
195
+ variant="solid"
196
+ style={{
197
+ backgroundColor: color,
198
+ }}
199
+ >
200
+ {" "}
201
+ </IconButton>
202
+ }
203
+ />
204
+ <IconToggleButton
205
+ defaultPressed={false}
206
+ aria-label="erase"
207
+ onPressedChange={handleToggleEraseMode}
208
+ >
209
+ <RiIcon icon="RiEraserFill" />
210
+ </IconToggleButton>
211
+ <Separator orientation="vertical" size="full" />
212
+ <IconButton aria-label="undo" onClick={handleUndoLast}>
213
+ <RiIcon icon="RiArrowGoBackLine" />
214
+ </IconButton>
215
+ <IconButton aria-label="undo" onClick={handleRedoLast}>
216
+ <RiIcon icon="RiArrowGoForwardLine" />
217
+ </IconButton>
218
+ <IconButton aria-label="undo all" onClick={handleUndoAll}>
219
+ <RiIcon icon="RiLoopLeftLine" />
220
+ </IconButton>
221
+ </div>
222
+ <div className="flex justify-end gap-2">
223
+ <IconButton
224
+ aria-label="Save markup"
225
+ accentColor="primary"
226
+ onClick={handleSave}
227
+ >
228
+ <RiIcon icon="RiSaveLine" />
229
+ </IconButton>
230
+ <IconButton aria-label="Delete attachment" onClick={handleDelete}>
231
+ <RiIcon icon="RiDeleteBin2Line" />
232
+ </IconButton>
233
+ </div>
234
+ </div>
235
+ </ButtonGroup>
236
+ </div>
237
+ <div className="relative flex w-full grow items-center justify-center p-4">
238
+ {/**NOTE: Invisible. Its only purpose is for us to measure its size (which is automatically
239
+ set for img tags */}
240
+ <img
241
+ className="translate-xmax-h-[calc(100%-130px)] pointer-events-none invisible absolute top-[50%] left-[50%] max-w-svw translate-x-[-50%] translate-y-[-50%] object-contain"
242
+ alt="Photo attachment"
243
+ ref={watchResizeTarget}
244
+ src={file.objectURL}
245
+ />
246
+
247
+ {loading ? (
248
+ <Spinner />
249
+ ) : (
250
+ <ReactSketchCanvas
251
+ key={reset ? "1" : "0"}
252
+ backgroundImage={file.objectURL}
253
+ ref={canvasRef}
254
+ width={`${currentImageSize?.width || 0}px`}
255
+ height={`${currentImageSize?.height || 0}px`}
256
+ onStroke={handleCanvasChange}
257
+ strokeColor={color}
258
+ />
259
+ )}
260
+ </div>
261
+ </div>
262
+ </>
263
+ </RadixDialog.Content>
264
+ </RadixDialog.Portal>
265
+ </RadixDialog.Root>
266
+ )
267
+ })
268
+ ImageMarkup.displayName = "AttachmentEditor"
@@ -0,0 +1 @@
1
+ export * from "./ImageMarkup"
@@ -0,0 +1,57 @@
1
+ import { Button } from "@overmap-ai/blocks"
2
+ import type { Meta } from "@storybook/react"
3
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
4
+
5
+ import { ImageViewer } from "./ImageViewer"
6
+
7
+ const meta = {
8
+ title: "Components/ImageViewer",
9
+ component: ImageViewer,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ disablePanel: true,
13
+ },
14
+ } satisfies Meta<typeof ImageViewer>
15
+
16
+ export default meta
17
+ // type Story = StoryObj<typeof meta>
18
+
19
+ export const Basic = () => {
20
+ const inputRef = useRef<HTMLInputElement>(null)
21
+ const [file, setFile] = useState<File | null>(null)
22
+ const [open, setOpen] = useState(false)
23
+
24
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
25
+ if (e.target.files) {
26
+ const files = Array.from(e.target.files)
27
+ if (files.length > 0) {
28
+ setFile(files[0] || null)
29
+ }
30
+ }
31
+ }, [])
32
+
33
+ console.log(file)
34
+ return (
35
+ <>
36
+ <input ref={inputRef} type="file" onChange={handleChange} />
37
+ <Button
38
+ onClick={() => {
39
+ setOpen(!open)
40
+ }}
41
+ >
42
+ open
43
+ </Button>
44
+ {file && open && (
45
+ <ImageViewer
46
+ file={file}
47
+ onClose={() => {
48
+ setOpen(false)
49
+ }}
50
+ onDelete={() => {
51
+ setFile(null)
52
+ }}
53
+ />
54
+ )}
55
+ </>
56
+ )
57
+ }
@@ -0,0 +1,124 @@
1
+ import { ButtonGroup, IconButton, RiIcon, useViewportSize } from "@overmap-ai/blocks"
2
+ import { downloadFile } from "@overmap-ai/core"
3
+ import * as RadixDialog from "@radix-ui/react-dialog"
4
+ import { memo, useCallback, useLayoutEffect, useRef } from "react"
5
+
6
+ import { FileBadge } from "../FileBadge"
7
+
8
+ interface ImageViewerProps {
9
+ file: File
10
+ onClose: () => void
11
+ onDelete?: (file: File) => void
12
+ }
13
+ export const ImageViewer = memo((props: ImageViewerProps) => {
14
+ const { file, onClose, onDelete } = props
15
+ const { md } = useViewportSize()
16
+ const imageRef = useRef<HTMLImageElement>(null)
17
+ const imageContainerRef = useRef<HTMLDivElement>(null)
18
+ const originalImageSize = useRef<{ width: number; height: number }>(null)
19
+
20
+ const adjustSize = useCallback(() => {
21
+ if (!imageContainerRef.current || !imageRef.current || !originalImageSize.current) return
22
+
23
+ const heightDifference = originalImageSize.current.height - imageContainerRef.current.clientHeight
24
+ const widthDifference = originalImageSize.current.width - imageContainerRef.current.clientWidth
25
+
26
+ if (heightDifference >= 0 || widthDifference >= 0) {
27
+ if (widthDifference > heightDifference) {
28
+ imageRef.current.style.width = `${imageContainerRef.current.clientWidth}px`
29
+ imageRef.current.style.height = "unset"
30
+ } else {
31
+ imageRef.current.style.height = `${imageContainerRef.current.clientHeight}px`
32
+ imageRef.current.style.width = "unset"
33
+ }
34
+ } else {
35
+ imageRef.current.style.width = `${originalImageSize.current.width}px`
36
+ imageRef.current.style.height = `${originalImageSize.current.height}px`
37
+ }
38
+ }, [])
39
+
40
+ const handleDelete = useCallback(() => {
41
+ if (onDelete) onDelete(file)
42
+ }, [file, onDelete])
43
+
44
+ const handleDownload = useCallback(() => {
45
+ downloadFile(file)
46
+ }, [file])
47
+
48
+ useLayoutEffect(() => {
49
+ const image = new Image()
50
+ image.onload = () => {
51
+ originalImageSize.current = {
52
+ width: image.width,
53
+ height: image.height,
54
+ }
55
+ // once we have stored the original image size, adjust the size of the image to the appropriate size
56
+ adjustSize()
57
+ }
58
+ image.src = URL.createObjectURL(file)
59
+ /*NOTE: adjustSize function has no dependencies, so the only thing causing this hook to rerun
60
+ * is if file changes, which is as desired since that will require loading a new image.
61
+ */
62
+ }, [adjustSize, file])
63
+
64
+ useLayoutEffect(() => {
65
+ if (!imageContainerRef.current) return
66
+ const resizeObserver = new ResizeObserver(() => {
67
+ // we want to adjust the size of the image as its container is resized
68
+ adjustSize()
69
+ })
70
+ resizeObserver.observe(imageContainerRef.current)
71
+ return () => {
72
+ resizeObserver.disconnect()
73
+ }
74
+ }, [adjustSize])
75
+
76
+ return (
77
+ <RadixDialog.Root open onOpenChange={onClose}>
78
+ <RadixDialog.Portal>
79
+ <RadixDialog.Overlay className="light:bg-(--black-a6) fixed inset-0 dark:bg-(--black-a8)" />
80
+ <RadixDialog.Content className="fixed inset-0">
81
+ <div className="flex h-full w-full flex-col">
82
+ <div className="flex h-max w-full items-center bg-(--color-background) p-2">
83
+ <ButtonGroup className="w-full" accentColor="base" variant="soft">
84
+ <div className="grid w-full grid-cols-3">
85
+ <div className="flex justify-start gap-2 items-center">
86
+ <IconButton onClick={onClose} aria-label="close">
87
+ <RiIcon icon="RiCloseLine" />
88
+ </IconButton>
89
+ <IconButton onClick={handleDownload} aria-label="close">
90
+ <RiIcon icon="RiDownload2Line" />
91
+ </IconButton>
92
+ </div>
93
+ <div className="flex justify-center">
94
+ <FileBadge
95
+ file={file}
96
+ accentColor="base"
97
+ truncateLength={!md ? 25 : undefined}
98
+ />
99
+ </div>
100
+ <div className="flex justify-end">
101
+ {!!onDelete && (
102
+ <IconButton onClick={handleDelete} aria-label="close">
103
+ <RiIcon icon="RiDeleteBin2Line" />
104
+ </IconButton>
105
+ )}
106
+ </div>
107
+ </div>
108
+ </ButtonGroup>
109
+ </div>
110
+ <div className="h-full w-full overflow-hidden p-5">
111
+ <div
112
+ className="flex h-full w-full items-center justify-center overflow-hidden"
113
+ ref={imageContainerRef}
114
+ >
115
+ <img ref={imageRef} src={URL.createObjectURL(file)} alt="" />
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </RadixDialog.Content>
120
+ </RadixDialog.Portal>
121
+ </RadixDialog.Root>
122
+ )
123
+ })
124
+ ImageViewer.displayName = "ImageViewer"
@@ -0,0 +1 @@
1
+ export const SUPPORTED_IMAGE_FILE_TYPES = ["image/jpeg", "image/png", "image/svg+xml"]
@@ -0,0 +1,2 @@
1
+ export * from "./constants"
2
+ export * from "./ImageViewer"
@@ -0,0 +1,55 @@
1
+ import { Button } from "@overmap-ai/blocks"
2
+ import type { Meta } from "@storybook/react"
3
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
4
+
5
+ import { PDFViewer } from "./PDFViewer"
6
+
7
+ const meta = {
8
+ title: "Components/PDFViewer",
9
+ component: PDFViewer,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ disablePanel: true,
13
+ },
14
+ } satisfies Meta<typeof PDFViewer>
15
+
16
+ export default meta
17
+
18
+ export const Basic = () => {
19
+ const inputRef = useRef<HTMLInputElement>(null)
20
+ const [file, setFile] = useState<File | null>(null)
21
+ const [open, setOpen] = useState(false)
22
+
23
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
24
+ if (e.target.files) {
25
+ const files = Array.from(e.target.files)
26
+ if (files.length > 0) {
27
+ setFile(files[0] || null)
28
+ }
29
+ }
30
+ }, [])
31
+
32
+ return (
33
+ <>
34
+ <input ref={inputRef} type="file" onChange={handleChange} />
35
+ <Button
36
+ onClick={() => {
37
+ setOpen(!open)
38
+ }}
39
+ >
40
+ open
41
+ </Button>
42
+ {file && open && (
43
+ <PDFViewer
44
+ file={file}
45
+ onClose={() => {
46
+ setOpen(false)
47
+ }}
48
+ onDelete={() => {
49
+ setFile(null)
50
+ }}
51
+ />
52
+ )}
53
+ </>
54
+ )
55
+ }