|
| 1 | +<!-- ABOUTME: Wizard exit confirmation dialog — offers saving the in-progress |
| 2 | + report as a draft or discarding it; cancelling keeps the user in the wizard. --> |
| 3 | +<script setup lang="ts"> |
| 4 | +import { ref, onMounted, useTemplateRef } from 'vue' |
| 5 | +
|
| 6 | +import { PhilaButton, CloseButton } from '@phila/phila-ui-button' |
| 7 | +import { Tags } from '@phila/phila-ui-tags' |
| 8 | +import { Icon } from '@phila/phila-ui-core' |
| 9 | +import { IconPencil, IconBackwardStep, IconForwardStep } from '@phila/phila-ui-core/icons' |
| 10 | +import { DrawingCanvas } from '@pinboard/ui' |
| 11 | +import type { Dimensions } from '@/types/wizard' |
| 12 | +
|
| 13 | +const open = defineModel<boolean>('open') |
| 14 | +const complete = defineModel<boolean>('complete') |
| 15 | +const file = defineModel<string>('file', { default: '' }) |
| 16 | +const scale = defineModel<Dimensions>('scale') |
| 17 | +
|
| 18 | +const title = 'Show us where the issue appears in your photo' |
| 19 | +const note = `Draw a circle around where the issue appears, or skip ahead to the next step.` |
| 20 | +const inkColor = |
| 21 | + getComputedStyle(document.documentElement) |
| 22 | + .getPropertyValue('--phillies-red-500-phillies-red') |
| 23 | + .trim() || '#CC3000' |
| 24 | +
|
| 25 | +const dialog = ref<HTMLDialogElement | null>(null) |
| 26 | +const canvasContainerRef = useTemplateRef('canvasContainerRef') |
| 27 | +const canvasRef = useTemplateRef('canvasRef') |
| 28 | +const canvasHeight = ref(NaN) |
| 29 | +const canvasWidth = ref(NaN) |
| 30 | +const uploadedImage = ref<HTMLImageElement | undefined>(undefined) |
| 31 | +const imageDim = ref<Dimensions>({ |
| 32 | + height: NaN, |
| 33 | + width: NaN, |
| 34 | +}) |
| 35 | +
|
| 36 | +const canvasBackground = { |
| 37 | + 'background-image': `url(${file.value})`, |
| 38 | + 'background-repeat': 'no-repeat', |
| 39 | + 'background-position': 'center center', |
| 40 | + 'background-size': 'contain', |
| 41 | +} |
| 42 | +
|
| 43 | +onMounted(() => { |
| 44 | + dialog.value?.showModal() |
| 45 | + if (!canvasContainerRef.value) { |
| 46 | + throw new Error('Drawing canvas container failed to mount') |
| 47 | + } |
| 48 | + const containerDim: Dimensions = { |
| 49 | + height: canvasContainerRef.value.clientHeight, |
| 50 | + width: canvasContainerRef.value.clientWidth, |
| 51 | + } |
| 52 | + getImageDimensions(file.value).then((imageDimensions) => { |
| 53 | + imageDim.value = imageDimensions |
| 54 | + setCanvasScale(imageDimensions, containerDim) |
| 55 | + }) |
| 56 | +}) |
| 57 | +
|
| 58 | +function handleClose() { |
| 59 | + open.value = false |
| 60 | +} |
| 61 | +
|
| 62 | +function handleSkip() { |
| 63 | + setImageScale() |
| 64 | + complete.value = true |
| 65 | + open.value = false |
| 66 | +} |
| 67 | +
|
| 68 | +function handleNext() { |
| 69 | + const offCanvas = new OffscreenCanvas(imageDim.value.width, imageDim.value.height) |
| 70 | + if (!canvasRef.value?.drawingCanvas) { |
| 71 | + throw new Error('Ref for drawing canvas was undefined') |
| 72 | + } |
| 73 | +
|
| 74 | + const context = offCanvas.getContext('2d') |
| 75 | + if (!context) { |
| 76 | + throw new Error('Failed to get context from OffscreenCanvas') |
| 77 | + } |
| 78 | + createImageBitmap(canvasRef.value.drawingCanvas, { |
| 79 | + resizeWidth: imageDim.value.width, |
| 80 | + resizeHeight: imageDim.value.height, |
| 81 | + resizeQuality: 'high', |
| 82 | + }).then((markupImage) => { |
| 83 | + if (!uploadedImage.value) { |
| 84 | + throw new Error('Could not locate uploaded image') |
| 85 | + } |
| 86 | + context.drawImage(uploadedImage.value, 0, 0) |
| 87 | + context.drawImage(markupImage, 0, 0) |
| 88 | + URL.revokeObjectURL(file.value) |
| 89 | + offCanvas.convertToBlob({ type: 'image/png', quality: 1 }).then((markupBlob) => { |
| 90 | + file.value = URL.createObjectURL(markupBlob) |
| 91 | + handleSkip() |
| 92 | + }) |
| 93 | + }) |
| 94 | +} |
| 95 | +
|
| 96 | +function handleUndo() { |
| 97 | + canvasRef.value?.undoLine() |
| 98 | +} |
| 99 | +
|
| 100 | +function handleRedo() { |
| 101 | + canvasRef.value?.redoLine() |
| 102 | +} |
| 103 | +
|
| 104 | +function getImageDimensions(dataURL: string): Promise<Dimensions> { |
| 105 | + return new Promise((resolve) => { |
| 106 | + uploadedImage.value = new Image() |
| 107 | + uploadedImage.value.onload = () => { |
| 108 | + resolve({ |
| 109 | + height: uploadedImage.value?.height ?? NaN, |
| 110 | + width: uploadedImage.value?.width ?? NaN, |
| 111 | + }) |
| 112 | + } |
| 113 | + uploadedImage.value.src = dataURL |
| 114 | + }) |
| 115 | +} |
| 116 | +
|
| 117 | +function setCanvasScale(image: Dimensions, container: Dimensions) { |
| 118 | + const widthLtHeight = container.width <= container.height |
| 119 | + canvasHeight.value = |
| 120 | + (widthLtHeight ? Math.floor(image.height * container.width) / image.width : container.height) * |
| 121 | + 0.99 |
| 122 | + canvasWidth.value = |
| 123 | + (widthLtHeight |
| 124 | + ? container.width |
| 125 | + : Math.floor((image.width * container.height) / image.height)) * 0.99 |
| 126 | + scale.value = widthLtHeight |
| 127 | + ? { |
| 128 | + height: image.height / image.width, |
| 129 | + width: 1, |
| 130 | + } |
| 131 | + : { |
| 132 | + height: 1, |
| 133 | + width: image.width / image.height, |
| 134 | + } |
| 135 | +} |
| 136 | +
|
| 137 | +function setImageScale() { |
| 138 | + const widthLtHeight = imageDim.value.width <= imageDim.value.height |
| 139 | + scale.value = widthLtHeight |
| 140 | + ? { |
| 141 | + height: imageDim.value.height / imageDim.value.width, |
| 142 | + width: 1, |
| 143 | + } |
| 144 | + : { |
| 145 | + height: 1, |
| 146 | + width: imageDim.value.width / imageDim.value.height, |
| 147 | + } |
| 148 | +} |
| 149 | +</script> |
| 150 | + |
| 151 | +<template> |
| 152 | + <dialog |
| 153 | + ref="dialog" |
| 154 | + class="image-dialog" |
| 155 | + aria-labelledby="image-dialog-title" |
| 156 | + @close="handleClose" |
| 157 | + @cancel="handleClose" |
| 158 | + > |
| 159 | + <div class="image-dialog-close"> |
| 160 | + <CloseButton aria-label="Close image upload dialog" @click="handleClose" /> |
| 161 | + </div> |
| 162 | + |
| 163 | + <div id="image-dialog-title" class="image-dialog-title" v-text="title" /> |
| 164 | + <div /> |
| 165 | + <div class="image-dialog-callout"> |
| 166 | + <Icon :icon="IconPencil" size="extra-small" /> {{ note }} |
| 167 | + </div> |
| 168 | + <div /> |
| 169 | + <div ref="canvasContainerRef" class="image-dialog-canvas"> |
| 170 | + <DrawingCanvas |
| 171 | + v-if="open && canvasHeight && canvasWidth" |
| 172 | + ref="canvasRef" |
| 173 | + :height="canvasHeight" |
| 174 | + :width="canvasWidth" |
| 175 | + :options="{ strokeStyle: inkColor }" |
| 176 | + :style="canvasBackground" |
| 177 | + ></DrawingCanvas> |
| 178 | + </div> |
| 179 | + |
| 180 | + <div /> |
| 181 | + <div class="image-dialog-actions"> |
| 182 | + <Tags text="Redo" :icon="IconForwardStep" color="white" @click="handleRedo" /> |
| 183 | + <Tags text="Undo" :icon="IconBackwardStep" color="white" @click="handleUndo" /> |
| 184 | + </div> |
| 185 | + <div /> |
| 186 | + <div class="image-dialog-footer"> |
| 187 | + <PhilaButton variant="secondary" data-test="image-discard" @click="handleClose" |
| 188 | + >Cancel</PhilaButton |
| 189 | + > |
| 190 | + <PhilaButton |
| 191 | + :variant="canvasRef?.drawingComplete ? 'primary' : 'secondary'" |
| 192 | + data-test="image-save" |
| 193 | + @click="canvasRef?.drawingComplete ? handleNext() : handleSkip()" |
| 194 | + >{{ canvasRef?.drawingComplete ? 'Next' : 'Skip' }}</PhilaButton |
| 195 | + > |
| 196 | + </div> |
| 197 | + </dialog> |
| 198 | +</template> |
| 199 | + |
| 200 | +<style scoped> |
| 201 | +.image-dialog { |
| 202 | + position: fixed; |
| 203 | + top: 50%; |
| 204 | + left: 50%; |
| 205 | + transform: translate(-50%, -50%); |
| 206 | + display: grid; |
| 207 | + grid-template-areas: |
| 208 | + 'close' |
| 209 | + 'title' |
| 210 | + 'title-space' |
| 211 | + 'callout' |
| 212 | + 'callout-space' |
| 213 | + 'canvas' |
| 214 | + 'canvas-space' |
| 215 | + 'actions' |
| 216 | + 'actions-space' |
| 217 | + 'footer'; |
| 218 | + grid-template-rows: |
| 219 | + auto auto var(--spacing-l, 1.5rem) auto var(--spacing-m, 1rem) 1fr var(--spacing-s, 0.75rem) |
| 220 | + auto var(--spacing-3xl, 3rem) auto; |
| 221 | + width: clamp(10vw, 65rem, 90vw); |
| 222 | + height: clamp(10vh, 65rem, 95vh); |
| 223 | + padding: var(--spacing-xl, 2rem); |
| 224 | + border: none; |
| 225 | + border-radius: var(--border-radius-xl, 1.5rem); |
| 226 | + background: var(--Schemes-Background, #fff); |
| 227 | + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); |
| 228 | +} |
| 229 | +
|
| 230 | +dialog:not([open]) { |
| 231 | + display: none; |
| 232 | +} |
| 233 | +
|
| 234 | +.image-dialog::backdrop { |
| 235 | + background: rgba(0, 0, 0, 0.5); |
| 236 | +} |
| 237 | +
|
| 238 | +.image-dialog-close { |
| 239 | + margin-left: auto; |
| 240 | +} |
| 241 | +
|
| 242 | +.image-dialog-close > .phila-button { |
| 243 | + color: var(--Schemes-On-Surface-High, #000) !important; |
| 244 | +} |
| 245 | +
|
| 246 | +.image-dialog-title { |
| 247 | + grid-area: title; |
| 248 | + color: var(--Schemes-On-Surface-High, #000); |
| 249 | +
|
| 250 | + /* Subtitle/Subtitle 1 */ |
| 251 | + font-family: var(--Subtitle-font-subtitle-family, Montserrat); |
| 252 | + font-size: var(--Subtitle-Subtitle-1-font-subtitle-1-size, 1.5rem); |
| 253 | + font-style: normal; |
| 254 | + font-weight: 600; |
| 255 | + line-height: var(--Subtitle-Subtitle-1-font-subtitle-1-lineheight, 2.25rem); /* 150% */ |
| 256 | +} |
| 257 | +
|
| 258 | +.image-dialog-callout { |
| 259 | + grid-area: callout; |
| 260 | + display: grid; |
| 261 | + grid-template-columns: auto auto; |
| 262 | + place-items: center; |
| 263 | + padding: var(--spacing-m, 1rem); |
| 264 | + gap: var(--spacing-xs, 0.5rem); |
| 265 | + border-radius: var(--border-radius-m, 0.75rem); |
| 266 | + background: var(--Schemes-Info-Container, #dbefff); |
| 267 | + color: var(--Schemes-On-Secondary-Container, #000); |
| 268 | + /* Label/Default */ |
| 269 | + font-family: var(--Label-Default-font-label-default-family, Montserrat); |
| 270 | + font-size: var(--Label-Default-font-label-default-size, 1rem); |
| 271 | + font-style: normal; |
| 272 | + font-weight: 600; |
| 273 | + line-height: var(--Label-Default-font-label-default-lineheight, 1.5rem); /* 150% */ |
| 274 | +} |
| 275 | +
|
| 276 | +.image-dialog-canvas { |
| 277 | + grid-area: canvas; |
| 278 | + overflow: auto; |
| 279 | + display: grid; |
| 280 | + place-content: center; |
| 281 | + height: 1fr; |
| 282 | + width: 1fr; |
| 283 | +} |
| 284 | +
|
| 285 | +.image-dialog-actions { |
| 286 | + grid-area: actions; |
| 287 | + display: flex; |
| 288 | + margin-left: auto; |
| 289 | + gap: var(--spacing-xs, 0.5rem); |
| 290 | +} |
| 291 | +
|
| 292 | +.image-dialog-footer { |
| 293 | + grid-area: footer; |
| 294 | + display: flex; |
| 295 | + margin-left: auto; |
| 296 | + gap: var(--spacing-xs, 0.5rem); |
| 297 | +} |
| 298 | +</style> |
0 commit comments