Skip to content

Commit 90f93d4

Browse files
authored
Merge pull request #8695 from nextcloud-libraries/backport/8665/main
feat(NcReferenceWidget): allow to enable resizable widget height
2 parents 757ec3e + 717a755 commit 90f93d4

3 files changed

Lines changed: 196 additions & 6 deletions

File tree

l10n/messages.pot

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,9 @@ msgstr ""
586586
msgid "White"
587587
msgstr ""
588588

589+
msgid "Widget height"
590+
msgstr ""
591+
589592
msgid "Write a message …"
590593
msgstr ""
591594

src/components/NcRichText/NcReferenceWidget.vue

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
-->
55

66
<script setup lang="ts">
7+
import type { Ref } from 'vue'
78
import type { ReferenceWidgetObject } from './../../functions/reference/widgets.ts'
89
910
import { useElementSize, useIntersectionObserver } from '@vueuse/core'
1011
import { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
1112
import { routerKey, RouterLink } from 'vue-router'
1213
import NcButton from '../../components/NcButton/NcButton.vue'
1314
import { t } from '../../l10n.ts'
14-
import { destroyWidget, hasFullWidth, hasInteractiveView, isWidgetRegistered, renderWidget } from './../../functions/reference/widgets.ts'
15+
import { createElementId } from '../../utils/createElementId.ts'
16+
import { destroyWidget, hasFullWidth, hasInteractiveView, isResizable, isWidgetRegistered, renderWidget } from './../../functions/reference/widgets.ts'
1517
import { getRoute } from './autolink.ts'
1618
1719
const props = withDefaults(defineProps<{
@@ -34,13 +36,18 @@ const props = withDefaults(defineProps<{
3436
3537
/* 3 minutes outside of viewport before widget is removed from the DOM */
3638
const IDLE_TIMEOUT = 3 * 60 * 1000
39+
const RESIZE_KEYBOARD_STEP = 10
3740
3841
const router = inject(routerKey, null)
3942
43+
const widgetId = `nc-reference-widget-${createElementId()}`
44+
4045
const isVisible = ref(false)
4146
const customWidget = useTemplateRef('customWidget')
47+
const customWidgetContent = useTemplateRef('customWidgetContent')
4248
const widgetRoot = useTemplateRef('widgetRoot')
4349
const { width } = useElementSize(widgetRoot)
50+
const { height: customWidgetHeight } = useElementSize(customWidget)
4451
4552
useIntersectionObserver(widgetRoot, ([entry]) => {
4653
nextTick(() => {
@@ -51,6 +58,12 @@ useIntersectionObserver(widgetRoot, ([entry]) => {
5158
const showInteractive = ref(false)
5259
const rendered = ref(false)
5360
let idleTimeout: NodeJS.Timeout | null = null
61+
const isResizing = ref(false)
62+
const resizedHeight: Ref<number | null> = ref(null)
63+
const resizeStartY = ref(0)
64+
const resizeStartHeight = ref(0)
65+
const resizeMinHeight = ref(100)
66+
const resizeMaxHeight = ref(window.innerHeight - 120)
5467
5568
const isInteractive = computed(() => {
5669
return (!props.interactiveOptIn && props.interactive) || showInteractive.value
@@ -60,10 +73,25 @@ const referenceHasFullWidth = computed(() => {
6073
return hasFullWidth(props.reference.richObjectType)
6174
})
6275
76+
const isWidgetResizable = computed(() => {
77+
return isResizable(props.reference.richObjectType)
78+
})
79+
6380
const hasCustomWidget = computed(() => {
6481
return isWidgetRegistered(props.reference.richObjectType)
6582
})
6683
84+
const customWidgetStyle = computed(() => {
85+
if (!resizedHeight.value) {
86+
return null
87+
}
88+
return { height: `${resizedHeight.value}px !important` }
89+
})
90+
91+
const ariaValueNow = computed(() => {
92+
return Math.round(resizedHeight.value ?? customWidgetHeight.value)
93+
})
94+
6795
const referenceHasInteractiveView = computed(() => {
6896
return hasCustomWidget.value && hasInteractiveView(props.reference.richObjectType)
6997
})
@@ -142,6 +170,11 @@ watch(isVisible, (val) => {
142170
}, { immediate: true })
143171
144172
onBeforeUnmount(() => {
173+
if (idleTimeout) {
174+
clearTimeout(idleTimeout)
175+
idleTimeout = null
176+
}
177+
stopResize()
145178
destroyReferenceWidget()
146179
})
147180
@@ -157,20 +190,20 @@ function enableInteractive() {
157190
* Render the reference widget
158191
*/
159192
function renderReferenceWidget() {
160-
if (!customWidget.value) {
193+
if (!customWidgetContent.value) {
161194
return
162195
}
163196
164197
if (props.reference.richObjectType === 'open-graph') {
165198
return
166199
}
167200
168-
customWidget.value.innerHTML = ''
201+
customWidgetContent.value.innerHTML = ''
169202
170203
// create a separate element so we can rerender on the ref again
171204
const widget = document.createElement('div')
172205
widget.style.width = '100%'
173-
customWidget.value.appendChild(widget)
206+
customWidgetContent.value.appendChild(widget)
174207
nextTick(() => {
175208
// Waiting for the ref to become available
176209
renderWidget(widget, {
@@ -190,15 +223,115 @@ function destroyReferenceWidget() {
190223
rendered.value = false
191224
}
192225
}
226+
227+
/**
228+
* Initialize resize limits
229+
*/
230+
function initResizeLimits() {
231+
// Use min/max height from rendered widget element if set
232+
const widgetEl = customWidgetContent.value?.firstElementChild
233+
if (widgetEl) {
234+
const computedStyle = window.getComputedStyle(widgetEl)
235+
const parsedMin = parseFloat(computedStyle.minHeight)
236+
const parsedMax = parseFloat(computedStyle.maxHeight)
237+
resizeMinHeight.value = parsedMin > 0 ? parsedMin : 100
238+
resizeMaxHeight.value = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : (window.innerHeight - 120)
239+
} else {
240+
resizeMinHeight.value = 100
241+
resizeMaxHeight.value = window.innerHeight - 120
242+
}
243+
}
244+
245+
/**
246+
* Start resize via dragging
247+
*
248+
* @param event - the pointer event
249+
*/
250+
function startResize(event: PointerEvent) {
251+
if (!isWidgetResizable.value || !customWidget.value) {
252+
return
253+
}
254+
255+
initResizeLimits()
256+
isResizing.value = true
257+
resizeStartY.value = event.clientY
258+
resizeStartHeight.value = customWidget.value.getBoundingClientRect().height
259+
260+
window.addEventListener('pointermove', onResize)
261+
window.addEventListener('pointerup', stopResize)
262+
}
263+
264+
/**
265+
* Resize via keydown
266+
*
267+
* @param delta - the resize delta
268+
*/
269+
function onResizeKeydown(delta: number) {
270+
if (!isWidgetResizable.value || !customWidget.value) {
271+
return
272+
}
273+
274+
// Establish limits once per interaction
275+
initResizeLimits()
276+
277+
const currentHeight = resizedHeight.value ?? resizeStartHeight.value
278+
const next = currentHeight + delta
279+
resizedHeight.value = Math.min(resizeMaxHeight.value, Math.max(resizeMinHeight.value, next))
280+
}
281+
282+
/**
283+
* Resize via dragging
284+
*
285+
* @param event - the pointer event
286+
*/
287+
function onResize(event: PointerEvent) {
288+
if (!isResizing.value || !customWidget.value) {
289+
return
290+
}
291+
292+
const deltaY = event.clientY - resizeStartY.value
293+
resizedHeight.value = Math.min(resizeMaxHeight.value, Math.max(resizeMinHeight.value, resizeStartHeight.value + deltaY))
294+
}
295+
296+
/**
297+
* Stop resize via dragging
298+
*/
299+
function stopResize() {
300+
if (!isResizing.value) {
301+
return
302+
}
303+
304+
isResizing.value = false
305+
window.removeEventListener('pointermove', onResize)
306+
window.removeEventListener('pointerup', stopResize)
307+
}
193308
</script>
194309

195310
<template>
196311
<div ref="widgetRoot" :class="{ 'toggle-interactive': referenceHasInteractiveView && !isInteractive }">
197312
<div
198313
v-if="reference && hasCustomWidget"
314+
:id="widgetId"
199315
ref="customWidget"
200316
class="widget-custom"
201-
:class="{ 'full-width': referenceHasFullWidth }" />
317+
:class="{ 'full-width': referenceHasFullWidth }"
318+
:style="customWidgetStyle">
319+
<div ref="customWidgetContent" class="widget-custom__content" />
320+
<div
321+
v-if="isWidgetResizable"
322+
class="widget-custom__resize-handle"
323+
role="slider"
324+
tabindex="0"
325+
aria-orientation="vertical"
326+
:aria-label="t('Widget height')"
327+
:aria-valuenow="ariaValueNow"
328+
:aria-valuemin="resizeMinHeight"
329+
:aria-valuemax="resizeMaxHeight"
330+
:aria-controls="widgetId"
331+
@pointerdown.stop.prevent="startResize"
332+
@keydown.up.stop.prevent="onResizeKeydown(-RESIZE_KEYBOARD_STEP)"
333+
@keydown.down.stop.prevent="onResizeKeydown(RESIZE_KEYBOARD_STEP)" />
334+
</div>
202335

203336
<component
204337
:is="referenceWidgetLinkComponent"
@@ -241,12 +374,52 @@ function destroyReferenceWidget() {
241374
242375
.widget-custom {
243376
@include widget;
377+
position: relative;
244378
245379
&.full-width {
246380
width: var(--widget-full-width, 100%) !important;
247381
inset-inline-start: calc( (var(--widget-full-width, 100%) - 100%) / 2 * -1);
248382
position: relative;
249383
}
384+
385+
&__content {
386+
width: 100%;
387+
display: flex;
388+
flex-direction: column;
389+
}
390+
391+
&__resize-handle {
392+
position: absolute;
393+
bottom: 0;
394+
inset-inline-start: 0;
395+
width: 100%;
396+
height: calc(var(--default-grid-baseline) * 2);
397+
border-top: 1px solid var(--color-border);
398+
margin-top: -1px;
399+
cursor: row-resize;
400+
touch-action: none;
401+
402+
&::before, &::after {
403+
content: '';
404+
position: absolute;
405+
top: 50%;
406+
left: 50%;
407+
background-color: var(--color-border);
408+
margin-top: 1px;
409+
transform: translateX(-50%);
410+
width: 30px;
411+
height: 1px;
412+
}
413+
414+
&::before {
415+
margin-top: -2px;
416+
}
417+
418+
&:focus-visible {
419+
outline: 2px solid var(--color-primary-element);
420+
outline-offset: -4px;
421+
}
422+
}
250423
}
251424
252425
.widget-access {

src/functions/reference/widgets.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ReferenceWidgetProps {
3232
id: string
3333
hasInteractiveView: boolean
3434
fullWidth: boolean
35+
isResizable: boolean
3536
callback: ReferenceWidgetRenderCallback
3637
onDestroy: ReferenceWidgetDestroyCallback
3738
}
@@ -47,7 +48,10 @@ window._registerWidget ??= (id: string, callback: ReferenceWidgetRenderCallback,
4748
* @param id - Id tof the widget
4849
* @param callback - Render callback
4950
* @param onDestroy - Cleanup callback
50-
* @param props - Widget props
51+
* @param props - Widget props (all optional)
52+
* @param props.hasInteractiveView - Whether the widget exposes an interactive view (default: true)
53+
* @param props.fullWidth - Whether the widget renders at full container width (default: false)
54+
* @param props.isResizable - Whether the user can drag-resize the widget height (default: false)
5155
*/
5256
export function registerWidget(
5357
id: string,
@@ -58,6 +62,7 @@ export function registerWidget(
5862
const propsWithDefaults = {
5963
hasInteractiveView: true,
6064
fullWidth: false,
65+
isResizable: false,
6166
...props,
6267
}
6368

@@ -138,3 +143,12 @@ export function hasInteractiveView(id: string): boolean {
138143
export function hasFullWidth(id: string): boolean {
139144
return !!window._vue_richtext_widgets[id]?.fullWidth
140145
}
146+
147+
/**
148+
* Check if the widget is resizable.
149+
*
150+
* @param id - Id of the widget
151+
*/
152+
export function isResizable(id: string): boolean {
153+
return !!window._vue_richtext_widgets[id]?.isResizable
154+
}

0 commit comments

Comments
 (0)