diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 0531548e68..2924254d30 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -101,7 +101,8 @@ - - + @@ -161,6 +162,27 @@ import CardItem from '../cards/CardItem.vue' import '@nextcloud/dialogs/style.css' +// Rendering every card of every stack at once does not scale: boards with +// many cards per list (50+) end up with thousands of DOM nodes, which makes +// every layout-invalidating interaction (e.g. opening the card sidebar) slow. +// +// A full virtual-scroller/recycling approach was intentionally avoided here: +// vue-smooth-dnd (see node_modules/vue-smooth-dnd) measures the *real* DOM +// children of the drag container at runtime (via the underlying smooth-dnd +// library) and maps drop positions back to array indices 1:1 with rendered +// children. Recycling/windowing an arbitrary middle slice of the list would +// desync those indices and break drag-and-drop. +// +// Instead we render a contiguous *prefix* of the card list (cards 0..N) and +// grow that prefix as the user scrolls down within the stack, or when a new +// card is added. Because it is always a prefix starting at index 0, the +// existing index-based drag-and-drop math (payloadForCard/onDropCard) stays +// correct without any offset adjustments. Lists at or below the threshold +// are rendered in full, unchanged from previous behaviour. +const CARD_RENDER_THRESHOLD = 30 +const CARD_RENDER_BATCH_SIZE = 30 +const CARD_RENDER_SCROLL_MARGIN = 400 + export default { name: 'Stack', components: { @@ -201,6 +223,7 @@ export default { total: 0, current: null, }, + renderedCardCount: CARD_RENDER_THRESHOLD, } }, computed: { @@ -223,6 +246,15 @@ export default { isDoneColumn() { return !!this.stack.isDoneColumn }, + // See the comment above CARD_RENDER_THRESHOLD for why this is a + // growing prefix of `cardsByStack` rather than a virtualized/ + // recycled window. + visibleCards() { + if (this.cardsByStack.length <= CARD_RENDER_THRESHOLD) { + return this.cardsByStack + } + return this.cardsByStack.slice(0, this.renderedCardCount) + }, dragHandleSelector() { return this.canEdit && !this.showArchived ? null : '.no-drag' }, @@ -249,6 +281,11 @@ export default { mounted() { this.setupAutoscrollOnDrag() + this.setupCardWindowScrollListener() + }, + + beforeDestroy() { + this.teardownCardWindowScrollListener() }, methods: { @@ -333,6 +370,9 @@ export default { }) this.newCardTitle = '' this.showAddCard = true + // A newly created card must be visible immediately, even if it + // landed past the currently rendered prefix of a windowed list. + this.renderedCardCount = this.cardsByStack.length this.$nextTick(() => { this.$refs.newCardInput.focus() this.animate = false @@ -380,6 +420,33 @@ export default { timer = window.setInterval(() => autoscroll(e), 25) }) }, + setupCardWindowScrollListener() { + const el = this.$refs.dndContainer?.$el + if (!el) { + return + } + this.cardWindowScrollEl = el + this.onCardWindowScroll = () => { + if (this.renderedCardCount >= this.cardsByStack.length) { + return + } + const { scrollTop, scrollHeight, clientHeight } = el + if (scrollHeight - (scrollTop + clientHeight) < CARD_RENDER_SCROLL_MARGIN) { + this.renderedCardCount = Math.min( + this.cardsByStack.length, + this.renderedCardCount + CARD_RENDER_BATCH_SIZE, + ) + } + } + el.addEventListener('scroll', this.onCardWindowScroll, { passive: true }) + }, + teardownCardWindowScrollListener() { + if (this.cardWindowScrollEl && this.onCardWindowScroll) { + this.cardWindowScrollEl.removeEventListener('scroll', this.onCardWindowScroll) + } + this.cardWindowScrollEl = null + this.onCardWindowScroll = null + }, }, } diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 1bd3457b91..d89e610057 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -117,6 +117,12 @@ export default { return { textAppAvailable: !!window.OCA?.Text?.createEditor, editor: null, + // Set in beforeDestroy() and checked by every async callback that + // touches `this.editor`/the component instance, so that work left + // over from a card switch (this component is remounted per + // card.id, see CardSidebarTabDetails.vue) never reaches into a + // torn-down editor/ProseMirror view. + isDestroyed: false, keyExitState: 0, descriptionOld: '', description: '', @@ -181,7 +187,7 @@ export default { this.descriptionOld = newCard.description this.description = newCard.description - if (this.editor) { + if (this.editor && !this.isDestroyed) { this.editor.setContent(this.description) } showWarning(t('deck', 'The description has been changed by another user.'), { timeout: 3000 }) @@ -192,22 +198,43 @@ export default { this.setupEditor() }, async beforeDestroy() { + this.isDestroyed = true await this.destroyEditor() }, methods: { async setupEditor() { await this.destroyEditor() + if (this.isDestroyed) { + return + } this.descriptionLastEdit = 0 this.descriptionOld = this.card.description this.description = this.card.description - this.editor = await window.OCA.Text.createEditor({ + + // Defer the (potentially expensive) editor mount until after the + // current render cycle - e.g. the card sidebar/modal opening + // transition - has settled, so it does not compete with it and + // so a fast card switch has a chance to flip `isDestroyed` + // before we ever touch window.OCA.Text. + await this.$nextTick() + if (this.isDestroyed || !this.$refs.editor) { + return + } + + const editor = await window.OCA.Text.createEditor({ el: this.$refs.editor, content: this.card.description, readOnly: !this.canEdit, onLoaded: () => { + if (this.isDestroyed) { + return + } this.descriptionLastEdit = 0 }, onUpdate: ({ markdown }) => { + if (this.isDestroyed) { + return + } if (this.description === markdown) { return } @@ -215,14 +242,32 @@ export default { this.updateDescription() }, onFileInsert: () => { + if (this.isDestroyed) { + return + } this.showAttachmentModal() }, }) + if (this.isDestroyed) { + // The component (and its DOM node passed as `el` above) was + // torn down while OCA.Text.createEditor() was still + // resolving - e.g. the user clicked another card before the + // editor finished mounting. Leaving this editor assigned to + // `this.editor` (or just dropped) is exactly what produces + // the "[tiptap error]: The editor view is not available" + // console error later, since it is bound to a detached + // element and nothing else will ever call destroy() on it. + editor?.destroy?.() + return + } + + this.editor = editor }, async destroyEditor() { await this.saveDescription() this?.editor?.destroy() + this.editor = null }, addKeyListeners() { this.$refs.markdownEditor.easymde.codemirror.on('keydown', (a, b) => { @@ -264,7 +309,7 @@ export default { // We need to strip those as text does not support rtl yet, so we cannot insert them separately const stripRTLO = (text) => text.replaceAll('\u202e', '') const fileName = stripRTLO(attachment.extendedData.info.filename) + '.' + stripRTLO(attachment.extendedData.info.extension) - if (this.editor) { + if (this.editor && !this.isDestroyed) { this.editor.insertAtCursor( asImage ? `${attachment.data}`