Skip to content

Commit bb74a96

Browse files
authored
Merge pull request #8665 from nextcloud-libraries/feat/stable8/resize_custom_widget_height
[stable8] feat(NcReferenceWidget): allow to enable resizable widget height
2 parents 488cb5a + 8160e7f commit bb74a96

3 files changed

Lines changed: 180 additions & 6 deletions

File tree

l10n/messages.pot

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,5 +493,8 @@ msgstr ""
493493
msgid "White"
494494
msgstr ""
495495

496+
msgid "Widget height"
497+
msgstr ""
498+
496499
msgid "Write a message …"
497500
msgstr ""

src/components/NcRichText/NcReferenceWidget.vue

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,29 @@
77
<div ref="widgetRoot" :class="{ 'toggle-interactive': hasInteractiveView && !isInteractive }">
88
<div
99
v-if="reference && hasCustomWidget"
10+
:id="widgetId"
1011
ref="customWidget"
1112
class="widget-custom"
12-
:class="{ 'full-width': hasFullWidth }" />
13+
:class="{
14+
'full-width': hasFullWidth,
15+
}"
16+
:style="customWidgetStyle">
17+
<div ref="customWidgetContent" class="widget-custom__content" />
18+
<div
19+
v-if="isResizable"
20+
class="widget-custom__resize-handle"
21+
role="slider"
22+
tabindex="0"
23+
aria-orientation="vertical"
24+
:aria-label="t('Widget height')"
25+
:aria-valuenow="ariaValueNow"
26+
:aria-valuemin="resizeMinHeight"
27+
:aria-valuemax="resizeMaxHeight"
28+
:aria-controls="widgetId"
29+
@pointerdown.stop.prevent="startResize"
30+
@keydown.up.stop.prevent="onResizeKeydown(-RESIZE_KEYBOARD_STEP)"
31+
@keydown.down.stop.prevent="onResizeKeydown(RESIZE_KEYBOARD_STEP)" />
32+
</div>
1333

1434
<component
1535
:is="referenceWidgetLinkComponent"
@@ -42,10 +62,12 @@ import { nextTick, ref } from 'vue'
4262
import { RouterLink } from 'vue-router'
4363
import NcButton from '../../components/NcButton/index.js'
4464
import { t } from '../../l10n.js'
45-
import { destroyWidget, hasFullWidth, hasInteractiveView, isWidgetRegistered, renderWidget } from './../../functions/reference/widgets.ts'
65+
import { createElementId } from '../../utils/createElementId.ts'
66+
import { destroyWidget, hasFullWidth, hasInteractiveView, isResizable, isWidgetRegistered, renderWidget } from './../../functions/reference/widgets.ts'
4667
import { getRoute } from './autolink.js'
4768
4869
const IDLE_TIMEOUT = 3 * 60 * 1000 // 3 minutes outside of viewport before widget is removed from the DOM
70+
const RESIZE_KEYBOARD_STEP = 10
4971
5072
export default {
5173
name: 'NcReferenceWidget',
@@ -73,10 +95,14 @@ export default {
7395
},
7496
7597
setup() {
98+
const widgetId = `nc-reference-widget-${createElementId()}`
99+
76100
const isVisible = ref(false)
77101
// This is the widget root node
78102
const widgetRoot = ref()
103+
const customWidget = ref()
79104
const { width } = useElementSize(widgetRoot)
105+
const { height: customWidgetHeight } = useElementSize(customWidget)
80106
81107
useIntersectionObserver(widgetRoot, ([entry]) => {
82108
nextTick(() => {
@@ -88,6 +114,10 @@ export default {
88114
width,
89115
isVisible,
90116
widgetRoot,
117+
customWidget,
118+
customWidgetHeight,
119+
widgetId,
120+
RESIZE_KEYBOARD_STEP,
91121
}
92122
},
93123
@@ -96,6 +126,12 @@ export default {
96126
showInteractive: false,
97127
rendered: false,
98128
idleTimeout: null,
129+
isResizing: false,
130+
resizedHeight: null,
131+
resizeStartY: 0,
132+
resizeStartHeight: 0,
133+
resizeMinHeight: 100,
134+
resizeMaxHeight: window.innerHeight - 120,
99135
}
100136
},
101137
@@ -108,10 +144,25 @@ export default {
108144
return hasFullWidth(this.reference.richObjectType)
109145
},
110146
147+
isResizable() {
148+
return isResizable(this.reference.richObjectType)
149+
},
150+
111151
hasCustomWidget() {
112152
return isWidgetRegistered(this.reference.richObjectType)
113153
},
114154
155+
customWidgetStyle() {
156+
if (!this.resizedHeight) {
157+
return null
158+
}
159+
return { height: `${this.resizedHeight}px !important` }
160+
},
161+
162+
ariaValueNow() {
163+
return Math.round(this.resizedHeight ?? this.customWidgetHeight)
164+
},
165+
115166
hasInteractiveView() {
116167
return isWidgetRegistered(this.reference.richObjectType) && hasInteractiveView(this.reference.richObjectType)
117168
},
@@ -197,6 +248,11 @@ export default {
197248
},
198249
199250
beforeDestroy() {
251+
if (this.idleTimeout) {
252+
clearTimeout(this.idleTimeout)
253+
this.idleTimout = null
254+
}
255+
this.stopResize()
200256
this.destroyWidget()
201257
},
202258
@@ -209,20 +265,20 @@ export default {
209265
},
210266
211267
renderWidget() {
212-
if (!this.$refs.customWidget) {
268+
if (!this.$refs.customWidgetContent) {
213269
return
214270
}
215271
216272
if (this?.reference?.richObjectType === 'open-graph') {
217273
return
218274
}
219275
220-
this.$refs.customWidget.innerHTML = ''
276+
this.$refs.customWidgetContent.innerHTML = ''
221277
222278
// create a separate element so we can rerender on the ref again
223279
const widget = document.createElement('div')
224280
widget.style = 'width: 100%;'
225-
this.$refs.customWidget.appendChild(widget)
281+
this.$refs.customWidgetContent.appendChild(widget)
226282
this.$nextTick(() => {
227283
// Waiting for the ref to become available
228284
renderWidget(widget, {
@@ -239,6 +295,67 @@ export default {
239295
this.rendered = false
240296
}
241297
},
298+
299+
initResizeLimits() {
300+
// Use min/max height from rendered widget element if set
301+
const widgetEl = this.$refs.customWidgetContent?.firstElementChild
302+
if (widgetEl) {
303+
const computedStyle = window.getComputedStyle(widgetEl)
304+
const parsedMin = parseFloat(computedStyle.minHeight)
305+
const parsedMax = parseFloat(computedStyle.maxHeight)
306+
this.resizeMinHeight = parsedMin > 0 ? parsedMin : 100
307+
this.resizeMaxHeight = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : (window.innerHeight - 120)
308+
} else {
309+
this.resizeMinHeight = 100
310+
this.resizeMaxHeight = window.innerHeight - 120
311+
}
312+
},
313+
314+
startResize(event) {
315+
if (!this.isResizable || !this.$refs.customWidget) {
316+
return
317+
}
318+
319+
this.initResizeLimits()
320+
this.isResizing = true
321+
this.resizeStartY = event.clientY
322+
this.resizeStartHeight = this.$refs.customWidget.getBoundingClientRect().height
323+
324+
window.addEventListener('pointermove', this.onResize)
325+
window.addEventListener('pointerup', this.stopResize)
326+
},
327+
328+
onResizeKeydown(delta) {
329+
if (!this.isResizable || !this.$refs.customWidget) {
330+
return
331+
}
332+
333+
// Establish limits once per interaction
334+
this.initResizeLimits()
335+
336+
const currentHeight = this.resizedHeight ?? this.resizeStartHeight
337+
const next = currentHeight + delta
338+
this.resizedHeight = Math.min(this.resizeMaxHeight, Math.max(this.resizeMinHeight, next))
339+
},
340+
341+
onResize(event) {
342+
if (!this.isResizing || !this.$refs.customWidget) {
343+
return
344+
}
345+
346+
const deltaY = event.clientY - this.resizeStartY
347+
this.resizedHeight = Math.min(this.resizeMaxHeight, Math.max(this.resizeMinHeight, this.resizeStartHeight + deltaY))
348+
},
349+
350+
stopResize() {
351+
if (!this.isResizing) {
352+
return
353+
}
354+
355+
this.isResizing = false
356+
window.removeEventListener('pointermove', this.onResize)
357+
window.removeEventListener('pointerup', this.stopResize)
358+
},
242359
},
243360
}
244361
</script>
@@ -259,12 +376,52 @@ export default {
259376
260377
.widget-custom {
261378
@include widget;
379+
position: relative;
262380
263381
&.full-width {
264382
width: var(--widget-full-width, 100%) !important;
265383
inset-inline-start: calc( (var(--widget-full-width, 100%) - 100%) / 2 * -1);
266384
position: relative;
267385
}
386+
387+
&__content {
388+
width: 100%;
389+
display: flex;
390+
flex-direction: column;
391+
}
392+
393+
&__resize-handle {
394+
position: absolute;
395+
bottom: 0;
396+
inset-inline-start: 0;
397+
width: 100%;
398+
height: calc(var(--default-grid-baseline) * 2);
399+
border-top: 1px solid var(--color-border);
400+
margin-top: -1px;
401+
cursor: row-resize;
402+
touch-action: none;
403+
404+
&::before, &::after {
405+
content: '';
406+
position: absolute;
407+
top: 50%;
408+
left: 50%;
409+
background-color: var(--color-border);
410+
margin-top: 1px;
411+
transform: translateX(-50%);
412+
width: 30px;
413+
height: 1px;
414+
}
415+
416+
&::before {
417+
margin-top: -2px;
418+
}
419+
420+
&:focus-visible {
421+
outline: 2px solid var(--color-primary-element);
422+
outline-offset: -4px;
423+
}
424+
}
268425
}
269426
270427
.widget-access {

src/functions/reference/widgets.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ReferenceWidgetProps {
1919
id: string
2020
hasInteractiveView: boolean
2121
fullWidth: boolean
22+
isResizable: boolean
2223
callback: ReferenceWidgetRenderCallback
2324
onDestroy: ReferenceWidgetDestroyCallback
2425
}
@@ -34,7 +35,10 @@ window._registerWidget ??= (id: string, callback: ReferenceWidgetRenderCallback,
3435
* @param id - Id tof the widget
3536
* @param callback - Render callback
3637
* @param onDestroy - Cleanup callback
37-
* @param props - Widget props
38+
* @param props - Widget props (all optional)
39+
* @param props.hasInteractiveView - Whether the widget exposes an interactive view (default: true)
40+
* @param props.fullWidth - Whether the widget renders at full container width (default: false)
41+
* @param props.isResizable - Whether the user can drag-resize the widget height (default: false)
3842
*/
3943
export function registerWidget(
4044
id: string,
@@ -45,6 +49,7 @@ export function registerWidget(
4549
const propsWithDefaults = {
4650
hasInteractiveView: true,
4751
fullWidth: false,
52+
isResizable: false,
4853
...props,
4954
}
5055

@@ -125,3 +130,12 @@ export function hasInteractiveView(id: string): boolean {
125130
export function hasFullWidth(id: string): boolean {
126131
return !!window._vue_richtext_widgets[id]?.fullWidth
127132
}
133+
134+
/**
135+
* Check if the widget is resizable.
136+
*
137+
* @param id - Id of the widget
138+
*/
139+
export function isResizable(id: string): boolean {
140+
return !!window._vue_richtext_widgets[id]?.isResizable
141+
}

0 commit comments

Comments
 (0)