Skip to content

Commit d2ad80d

Browse files
committed
feat(draggable): implement draggable functionality for popups in Gmail and Writing Tools
1 parent c6b3655 commit d2ad80d

File tree

3 files changed

+221
-5
lines changed

3 files changed

+221
-5
lines changed

composables/useDraggable.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Ref, ref } from 'vue'
2+
3+
interface DragState {
4+
isDragging: boolean
5+
startX: number
6+
startY: number
7+
startLeft: number
8+
startTop: number
9+
}
10+
11+
interface DragOptions {
12+
handle?: Ref<HTMLElement | null>
13+
onDragStart?: () => void
14+
onDragEnd?: () => void
15+
onDrag?: (left: number, top: number) => void
16+
constrainToViewport?: boolean
17+
}
18+
19+
export function useDraggable(
20+
targetElement: Ref<HTMLElement | null>,
21+
options: DragOptions = {},
22+
) {
23+
const {
24+
handle,
25+
onDragStart,
26+
onDragEnd,
27+
onDrag,
28+
constrainToViewport = true,
29+
} = options
30+
31+
const isDragging = ref(false)
32+
const position = ref({ left: 0, top: 0 })
33+
34+
const dragState: DragState = {
35+
isDragging: false,
36+
startX: 0,
37+
startY: 0,
38+
startLeft: 0,
39+
startTop: 0,
40+
}
41+
42+
const handleMouseDown = (event: MouseEvent) => {
43+
const target = targetElement.value
44+
if (!target) return
45+
46+
// Check if the drag handle is being used
47+
const dragHandle = handle?.value || target
48+
if (!dragHandle.contains(event.target as Node)) return
49+
50+
event.preventDefault()
51+
event.stopPropagation()
52+
53+
const rect = target.getBoundingClientRect()
54+
55+
dragState.isDragging = true
56+
dragState.startX = event.clientX
57+
dragState.startY = event.clientY
58+
dragState.startLeft = rect.left
59+
dragState.startTop = rect.top
60+
61+
isDragging.value = true
62+
onDragStart?.()
63+
64+
// Add global event listeners
65+
document.addEventListener('mousemove', handleMouseMove, { passive: false })
66+
document.addEventListener('mouseup', handleMouseUp, { passive: false })
67+
68+
// Prevent text selection while dragging
69+
document.body.style.userSelect = 'none'
70+
}
71+
72+
const handleMouseMove = (event: MouseEvent) => {
73+
if (!dragState.isDragging || !targetElement.value) return
74+
75+
event.preventDefault()
76+
77+
const deltaX = event.clientX - dragState.startX
78+
const deltaY = event.clientY - dragState.startY
79+
80+
let newLeft = dragState.startLeft + deltaX
81+
let newTop = dragState.startTop + deltaY
82+
83+
// Constrain to viewport if enabled
84+
if (constrainToViewport) {
85+
const target = targetElement.value
86+
const rect = target.getBoundingClientRect()
87+
const viewportWidth = window.innerWidth
88+
const viewportHeight = window.innerHeight
89+
90+
// Prevent dragging outside viewport bounds
91+
newLeft = Math.max(0, Math.min(viewportWidth - rect.width, newLeft))
92+
newTop = Math.max(0, Math.min(viewportHeight - rect.height, newTop))
93+
}
94+
95+
position.value = { left: newLeft, top: newTop }
96+
onDrag?.(newLeft, newTop)
97+
98+
// Apply the position to the element
99+
if (targetElement.value) {
100+
targetElement.value.style.left = `${newLeft}px`
101+
targetElement.value.style.top = `${newTop}px`
102+
}
103+
}
104+
105+
const handleMouseUp = () => {
106+
dragState.isDragging = false
107+
isDragging.value = false
108+
109+
// Remove global event listeners
110+
document.removeEventListener('mousemove', handleMouseMove)
111+
document.removeEventListener('mouseup', handleMouseUp)
112+
113+
// Restore text selection
114+
document.body.style.userSelect = ''
115+
116+
onDragEnd?.()
117+
}
118+
119+
const initDraggable = () => {
120+
const target = targetElement.value
121+
if (!target) return
122+
123+
// Add mouse down listener to the drag handle or target
124+
const dragHandle = handle?.value || target
125+
dragHandle.addEventListener('mousedown', handleMouseDown, { passive: false })
126+
127+
// Set cursor style for drag handle
128+
dragHandle.style.cursor = 'move'
129+
130+
return () => {
131+
dragHandle.removeEventListener('mousedown', handleMouseDown)
132+
document.removeEventListener('mousemove', handleMouseMove)
133+
document.removeEventListener('mouseup', handleMouseUp)
134+
dragHandle.style.cursor = ''
135+
}
136+
}
137+
138+
const resetPosition = () => {
139+
position.value = { left: 0, top: 0 }
140+
if (targetElement.value) {
141+
targetElement.value.style.left = '0px'
142+
targetElement.value.style.top = '0px'
143+
}
144+
}
145+
146+
return {
147+
isDragging,
148+
position,
149+
initDraggable,
150+
resetPosition,
151+
}
152+
}

entrypoints/content/components/GmailTools/index.vue

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<div
1414
v-if="showReplyCard"
1515
ref="popupRef"
16-
class="popup bg-white fixed rounded-md z-50 transition-[width,top,left] shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
16+
class="popup bg-white fixed rounded-md z-50 shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
1717
:class="!popupPos ? 'opacity-0' : ''"
1818
:style="popupPos ? { top: popupPos.top + 'px', left: popupPos.left + 'px' } : {}"
1919
>
@@ -26,7 +26,7 @@
2626
<div
2727
v-if="showComposeCard"
2828
ref="composePopupRef"
29-
class="popup bg-white fixed rounded-md z-50 transition-[width,top,left] shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
29+
class="popup bg-white fixed rounded-md z-50 shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
3030
:class="!composePopupPos ? 'opacity-0' : ''"
3131
:style="composePopupPos ? { top: composePopupPos.top + 'px', left: composePopupPos.left + 'px' } : {}"
3232
>
@@ -47,6 +47,7 @@ import { useElementBounding } from '@vueuse/core'
4747
import { computed, onMounted, ref, shallowRef, watchEffect } from 'vue'
4848
import { ShadowRoot as ShadowRootComponent } from 'vue-shadow-dom'
4949
50+
import { useDraggable } from '@/composables/useDraggable'
5051
import { useLogger } from '@/composables/useLogger'
5152
import { injectStyleSheetToDocument, loadContentScriptStyleSheet } from '@/utils/css'
5253
import { getUserConfig } from '@/utils/user-config'
@@ -60,11 +61,15 @@ const rootElement = useRootElement()
6061
const styleSheet = shallowRef<CSSStyleSheet | null>(null)
6162
const shadowRootRef = ref<InstanceType<typeof ShadowRoot>>()
6263
const containerRef = ref<HTMLDivElement>()
63-
const popupRef = ref<HTMLDivElement>()
64-
const composePopupRef = ref<HTMLDivElement>()
64+
const popupRef = ref<HTMLDivElement | null>(null)
65+
const composePopupRef = ref<HTMLDivElement | null>(null)
6566
const clickedReplyButtonRef = ref<HTMLElement | null>(null) // for navigating current polish button and finding its parent compose dialog
6667
const clickedComposeButtonRef = ref<HTMLElement | null>(null) // for navigating current compose button and finding its parent compose dialog
6768
69+
// Drag handles for popup title bars
70+
const replyDragHandleRef = ref<HTMLElement | null>(null)
71+
const composeDragHandleRef = ref<HTMLElement | null>(null)
72+
6873
const userConfig = await getUserConfig()
6974
const enabled = userConfig.emailTools.enable.toRef()
7075
@@ -82,6 +87,15 @@ const showComposeCard = ref(false)
8287
const composeButtonElement = ref<HTMLElement | null>(null)
8388
const composePopupBounding = useElementBounding(composePopupRef)
8489
90+
// Initialize draggable functionality for both popups
91+
const replyDraggable = useDraggable(popupRef, {
92+
handle: replyDragHandleRef,
93+
})
94+
95+
const composeDraggable = useDraggable(composePopupRef, {
96+
handle: composeDragHandleRef,
97+
})
98+
8599
const popupPos = computed(() => {
86100
if (!replyButtonElement.value || popupBounding.height.value === 0 || popupBounding.width.value === 0) {
87101
return null
@@ -140,11 +154,20 @@ const onShowReplyCard = (buttonElement: HTMLElement, clickedButtonElement: HTMLE
140154
replyButtonElement.value = buttonElement
141155
clickedReplyButtonRef.value = clickedButtonElement
142156
showReplyCard.value = true
157+
158+
// Initialize dragging after popup is shown
159+
setTimeout(() => {
160+
if (popupRef.value) {
161+
replyDragHandleRef.value = popupRef.value.querySelector('.title') as HTMLElement
162+
replyDraggable.initDraggable()
163+
}
164+
}, 0)
143165
}
144166
145167
const onCloseReplyCard = () => {
146168
showReplyCard.value = false
147169
replyButtonElement.value = null
170+
replyDraggable.resetPosition()
148171
}
149172
150173
const onApplyReply = (text: string) => {
@@ -157,11 +180,20 @@ const onShowComposeCard = (buttonElement: HTMLElement, clickedButtonElement: HTM
157180
composeButtonElement.value = buttonElement
158181
clickedComposeButtonRef.value = clickedButtonElement
159182
showComposeCard.value = true
183+
184+
// Initialize dragging after popup is shown
185+
setTimeout(() => {
186+
if (composePopupRef.value) {
187+
composeDragHandleRef.value = composePopupRef.value.querySelector('.title') as HTMLElement
188+
composeDraggable.initDraggable()
189+
}
190+
}, 0)
160191
}
161192
162193
const onCloseComposeCard = () => {
163194
showComposeCard.value = false
164195
composeButtonElement.value = null
196+
composeDraggable.resetPosition()
165197
}
166198
167199
const onApplyCompose = (data: { subject: string, body: string }) => {

entrypoints/content/components/WritingTools/EditableEntry.vue

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
<div
6464
v-if="writingToolType"
6565
ref="popupRef"
66-
class="popup bg-white fixed rounded-md z-50 transition-[width,top,left] shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
66+
class="popup bg-white fixed rounded-md z-50 shadow-[0px_8px_16px_0px_#00000014,0px_4px_8px_0px_#00000014,0px_0px_0px_1px_#00000014]"
6767
:class="!popupPos ? 'opacity-0' : ''"
6868
:style="popupPos ? { top: popupPos.top + 'px', left: popupPos.left + 'px' } : {}"
6969
>
@@ -90,6 +90,7 @@ import IconSparkle from '@/assets/icons/writing-tools-sparkle.svg?component'
9090
import Button from '@/components/ui/Button.vue'
9191
import Text from '@/components/ui/Text.vue'
9292
import { useDeferredValue } from '@/composables/useDeferredValue'
93+
import { useDraggable } from '@/composables/useDraggable'
9394
import { useRefSnapshot } from '@/composables/useRefSnapshot'
9495
import { MIN_SELECTION_LENGTH_TO_SHOW_WRITING_TOOLS } from '@/utils/constants'
9596
import { useI18n } from '@/utils/i18n'
@@ -111,6 +112,13 @@ const toolBarRef = ref<HTMLDivElement | null>(null)
111112
const popupRef = ref<HTMLDivElement | null>(null)
112113
const toolBarBounding = useElementBounding(toolBarRef)
113114
const popupBounding = useElementBounding(popupRef)
115+
116+
// Draggable functionality for toolbar and popup
117+
const toolBarDraggable = useDraggable(toolBarRef)
118+
const popupDragHandleRef = ref<HTMLElement | null>(null)
119+
const popupDraggable = useDraggable(popupRef, {
120+
handle: popupDragHandleRef,
121+
})
114122
const writingToolType = ref<WritingToolType | null>(null)
115123
const writingToolSelectedText = ref<string>('')
116124
const editableElementText = ref('')
@@ -120,6 +128,20 @@ const isShowToolBar = computed(() => {
120128
})
121129
const isShowToolBarDeferred = useDeferredValue(isShowToolBar, 200, (v) => !v)
122130
131+
// Initialize toolbar draggable when it becomes visible
132+
watch(isShowToolBarDeferred, (isVisible) => {
133+
if (isVisible) {
134+
setTimeout(() => {
135+
if (toolBarRef.value) {
136+
toolBarDraggable.initDraggable()
137+
}
138+
}, 0)
139+
}
140+
else {
141+
toolBarDraggable.resetPosition()
142+
}
143+
})
144+
123145
watch(() => props.editableElement, (newEl) => {
124146
isEditableFocus.value = newEl === document.activeElement || (document.hasFocus() && newEl.contains(document.activeElement))
125147
}, { immediate: true })
@@ -280,16 +302,26 @@ const updateSelectedText = () => {
280302
const onAction = async (action?: WritingToolType) => {
281303
if (!action) {
282304
writingToolType.value = null
305+
popupDraggable.resetPosition()
283306
return
284307
}
285308
snapshotForGeneratePopup.updateSnapshot()
286309
regenerateSymbol.value += 1 // Increment to trigger re-render
287310
writingToolType.value = action
311+
312+
// Initialize popup draggable after it's shown
313+
setTimeout(() => {
314+
if (popupRef.value) {
315+
popupDragHandleRef.value = popupRef.value.querySelector('.title') as HTMLElement
316+
popupDraggable.initDraggable()
317+
}
318+
}, 0)
288319
}
289320
290321
const onClosePopup = () => {
291322
writingToolType.value = null
292323
writingToolSelectedText.value = ''
324+
popupDraggable.resetPosition()
293325
}
294326
295327
const onClickToClosePopup = (ev: Event) => {

0 commit comments

Comments
 (0)