Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/components/board/Stack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@
</div>
</NcModal>

<Container :get-child-payload="payloadForCard(stack.id)"
<Container ref="dndContainer"
:get-child-payload="payloadForCard(stack.id)"
class="dnd-container"
group-name="stack"
data-click-closes-sidebar="true"
Expand All @@ -112,7 +113,7 @@
@drag-start="draggingCard = true"
@drag-end="draggingCard = false"
@drop="($event) => onDropCard(stack.id, $event)">
<Draggable v-for="card in cardsByStack" :key="card.id">
<Draggable v-for="card in visibleCards" :key="card.id">
<transition :appear="animate && !card.animated && (card.animated=true)"
:appear-class="'zoom-appear-class'"
:appear-active-class="'zoom-appear-active-class'">
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -201,6 +223,7 @@ export default {
total: 0,
current: null,
},
renderedCardCount: CARD_RENDER_THRESHOLD,
}
},
computed: {
Expand All @@ -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'
},
Expand All @@ -249,6 +281,11 @@ export default {

mounted() {
this.setupAutoscrollOnDrag()
this.setupCardWindowScrollListener()
},

beforeDestroy() {
this.teardownCardWindowScrollListener()
},

methods: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
},
}
</script>
Expand Down
51 changes: 48 additions & 3 deletions src/components/card/Description.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -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 })
Expand All @@ -192,37 +198,76 @@ 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
}
this.description = markdown
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) => {
Expand Down Expand Up @@ -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
? `<a href="${this.attachmentPreview(attachment)}"><img src="${this.attachmentPreview(attachment)}" alt="${attachment.data}" /></a>`
Expand Down