Commit b9169dc5 by Jonathan Thomas

New drag and drop re-arranging for clips. Also adding a new FX button to clips - work in progress.

parent 5f49e014
......@@ -11,22 +11,54 @@
</div>
<!-- Scrolling container for clips -->
<div class="container p-2 scrolling-container">
<div class="container p-2 scrolling-container" ref="scrollContainer">
<div class="col-12">
<div class="row gy-2 gx-2 mb-2" v-for="(clip, index) in thumbnailedClips" :key="clip.id">
<div v-if="isDragging && thumbnailedClips.length > 0"
class="drag-buffer drag-buffer-top"
@dragover.prevent="onDragOverBeforeFirst"
@drop.prevent="onDropBeforeFirst">
</div>
<div
class="row gy-2 gx-2 mb-2 clip-row"
v-for="(clip, index) in thumbnailedClips"
:key="clip.id"
:class="{ 'drag-source': draggingClip && draggingClip.id === clip.id }"
:draggable="thumbnailedClips.length > 1"
@dragstart="onDragStart($event, clip, index)"
@dragover.prevent="onDragOver($event, index)"
@dragleave="onDragLeave($event, index)"
@drop.prevent="onDrop($event, index)"
@dragend="onDragEnd"
>
<div class="col-12 text-center img-parent">
<div class="drop-indicator drop-indicator-top" v-if="shouldShowIndicator(index, 'before')"></div>
<div class="drop-indicator drop-indicator-bottom" v-if="shouldShowIndicator(index, 'after')"></div>
<!-- Clip sequence # badge -->
<span class="clip-badge badge">{{ index + 1 }}</span>
<!-- Clip text icon -->
<div class="context-menu">
<span @click.prevent="toggleText(clip)" class="menu-icon" title="Add Text">
<div class="context-menu" @mousedown.stop>
<span @click.prevent="toggleText(clip)"
class="menu-icon toggle-icon"
:class="{ active: isTextActive(clip) }"
title="Add Text"
draggable="false">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-textarea-t" viewBox="0 0 16 16">
<path d="M1.5 2.5A1.5 1.5 0 0 1 3 1h10a1.5 1.5 0 0 1 1.5 1.5v3.563a2 2 0 0 1 0 3.874V13.5A1.5 1.5 0 0 1 13 15H3a1.5 1.5 0 0 1-1.5-1.5V9.937a2 2 0 0 1 0-3.874V2.5zm1 3.563a2 2 0 0 1 0 3.874V13.5a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V9.937a2 2 0 0 1 0-3.874V2.5A.5.5 0 0 0 13 2H3a.5.5 0 0 0-.5.5v3.563zM2 7a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/>
<path d="M11.434 4H4.566L4.5 5.994h.386c.21-1.252.612-1.446 2.173-1.495l.343-.011v6.343c0 .537-.116.665-1.049.748V12h3.294v-.421c-.938-.083-1.054-.21-1.054-.748V4.488l.348.01c1.56.05 1.963.244 2.173 1.496h.386L11.434 4z"/>
</svg>
</span>
<span @click.prevent="toggleFx(clip)"
class="menu-icon toggle-icon"
:class="{ active: isFxActive(clip) }"
title="Add FX"
draggable="false">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16">
<path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/>
</svg>
</span>
<!-- Context menu for clip -->
<span class="btn-group dropstart menu-icon" title="Clip Menu">
......@@ -45,7 +77,7 @@
<!-- Clip text input -->
<div v-if="clip.json.hasOwnProperty('text')" class="mb-3 clip-text">
<input @blur="updateTextInput($event, clip)" type="text" class="form-control" maxlength="35" placeholder="Enter text here" :value="clip.json.text">
<input @blur="updateTextInput($event, clip)" @mousedown.stop type="text" class="form-control" maxlength="35" placeholder="Enter text here" :value="clip.json.text">
</div>
<!-- Clip length label -->
......@@ -54,9 +86,14 @@
</div>
<!-- Clip thumbnail image -->
<img @click="toggleSelection(clip)" :class="getSelectedClass(clip)" class="img-fluid img-thumbnail clip-thumbnail" @load="thumbnailLoaded($event, clip)" :title="clip.name" :src="clip.thumbnail"/>
<img @click="toggleSelection(clip)" :class="getSelectedClass(clip)" class="img-fluid img-thumbnail clip-thumbnail" @load="thumbnailLoaded($event, clip)" :title="clip.name" :src="clip.thumbnail" draggable="false"/>
</div>
</div>
<div v-if="isDragging && thumbnailedClips.length > 0"
class="drag-buffer drag-buffer-bottom"
@dragover.prevent="onDragOverAfterLast"
@drop.prevent="onDropAfterLast">
</div>
</div>
</div>
</div>
......@@ -108,7 +145,14 @@ export default {
data() {
return {
show_spinner: false,
exporting: false
exporting: false,
draggingClip: null,
draggingIndex: null,
dragOverIndex: null,
dragOverPosition: null,
autoScrollDirection: 0,
autoScrollFrame: null,
dragListenersActive: false
}
},
methods: {
......@@ -117,6 +161,21 @@ export default {
delete clipObj.json.text
} else {
clipObj.json.text = ""
if (clipObj.json.fx_enabled) {
delete clipObj.json.fx_enabled
}
}
let payload = { data: clipObj }
this.editClip(payload)
},
toggleFx(clipObj) {
if (clipObj.json.fx_enabled) {
delete clipObj.json.fx_enabled
} else {
clipObj.json.fx_enabled = true
if (Object.prototype.hasOwnProperty.call(clipObj.json, 'text')) {
delete clipObj.json.text
}
}
let payload = { data: clipObj }
this.editClip(payload)
......@@ -126,6 +185,229 @@ export default {
let payload = { data: clipObj }
this.editClip(payload)
},
isTextActive(clipObj) {
return Object.prototype.hasOwnProperty.call(clipObj.json, 'text')
},
isFxActive(clipObj) {
return !!clipObj.json.fx_enabled
},
onDragStart(event, clipObj, index) {
if (this.thumbnailedClips.length <= 1) {
event.preventDefault()
return
}
this.draggingClip = clipObj
this.draggingIndex = index
this.dragOverIndex = null
this.dragOverPosition = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', clipObj.id)
} catch (err) {
// ignore drag image errors
}
}
if (!this.dragListenersActive && typeof window !== 'undefined') {
window.addEventListener('dragover', this.onGlobalDragOver)
this.dragListenersActive = true
}
},
onDragOver(event, index) {
if (!this.draggingClip) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
const dropBefore = (event.clientY - bounds.top) < bounds.height / 2
this.dragOverIndex = index
this.dragOverPosition = dropBefore ? 'before' : 'after'
},
onDragLeave(event, index) {
if (!this.draggingClip) {
return
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return
}
if (this.dragOverIndex === index) {
this.dragOverIndex = null
this.dragOverPosition = null
}
},
onDrop(event, index) {
if (!this.draggingClip) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
const fallback = (event.clientY - bounds.top) < bounds.height / 2
const dropBefore = this.dragOverIndex === index && this.dragOverPosition
? this.dragOverPosition === 'before'
: fallback
this.handleDrop(index, dropBefore)
},
onGlobalDragOver(event) {
if (!this.draggingClip) {
this.stopAutoScroll()
return
}
const container = this.$refs.scrollContainer
if (!container) {
return
}
const rect = container.getBoundingClientRect()
const buffer = 35
const canScrollUp = container.scrollTop > 0
const canScrollDown = container.scrollTop + container.clientHeight < container.scrollHeight
if ((event.clientY < rect.top || event.clientY < rect.top + buffer) && canScrollUp) {
this.startAutoScroll(-1)
} else if ((event.clientY > rect.bottom || event.clientY > rect.bottom - buffer) && canScrollDown) {
this.startAutoScroll(1)
} else {
this.stopAutoScroll()
}
},
onDragOverBeforeFirst() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.dragOverIndex = 0
this.dragOverPosition = 'before'
},
onDropBeforeFirst() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.handleDrop(0, true)
},
onDragOverAfterLast() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.dragOverIndex = this.thumbnailedClips.length - 1
this.dragOverPosition = 'after'
},
onDropAfterLast() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.handleDrop(this.thumbnailedClips.length - 1, false)
},
onDragEnd() {
this.resetDragState()
},
handleDrop(targetIndex, dropBefore) {
if (!this.draggingClip) {
return
}
const current = this.draggingIndex
if (current === null || current === undefined || targetIndex === null || targetIndex === undefined) {
this.resetDragState()
return
}
targetIndex = Math.max(0, Math.min(targetIndex, this.thumbnailedClips.length - 1))
let dest = this.calculateDestinationIndex(current, targetIndex, dropBefore)
dest = Math.max(0, Math.min(dest, this.thumbnailedClips.length - 1))
if (dest === current) {
this.resetDragState()
return
}
let payload = { clip: this.draggingClip, current, dest }
this.moveClip(payload)
this.resetDragState()
},
calculateDestinationIndex(current, target, dropBefore) {
if (dropBefore) {
let dest = target
if (current < target) {
dest -= 1
}
return dest
}
let dest = target + 1
if (current <= target) {
dest -= 1
}
return Math.max(0, dest)
},
shouldShowIndicator(index, position) {
return !!this.draggingClip && this.dragOverIndex === index && this.dragOverPosition === position
},
resetDragState() {
this.draggingClip = null
this.draggingIndex = null
this.dragOverIndex = null
this.dragOverPosition = null
this.stopAutoScroll()
this.removeDragListeners()
},
startAutoScroll(direction) {
if (this.autoScrollDirection === direction) {
return
}
if (!direction) {
this.stopAutoScroll()
return
}
const raf = typeof window !== 'undefined' ? window.requestAnimationFrame : null
if (!raf) {
return
}
this.autoScrollDirection = direction
if (this.autoScrollFrame) {
const caf = typeof window !== 'undefined' ? window.cancelAnimationFrame : null
if (caf) {
caf(this.autoScrollFrame)
}
this.autoScrollFrame = null
}
this.autoScrollFrame = raf(() => this.performAutoScroll())
},
performAutoScroll() {
if (!this.autoScrollDirection) {
return
}
const container = this.$refs.scrollContainer
if (!container) {
this.stopAutoScroll()
return
}
const maxScrollTop = container.scrollHeight - container.clientHeight
if (maxScrollTop <= 0) {
this.stopAutoScroll()
return
}
const scrollDelta = this.autoScrollDirection * 12
const nextScroll = Math.max(0, Math.min(maxScrollTop, container.scrollTop + scrollDelta))
if (nextScroll === container.scrollTop) {
this.stopAutoScroll()
return
}
container.scrollTop = nextScroll
const raf = typeof window !== 'undefined' ? window.requestAnimationFrame : null
if (!raf) {
this.stopAutoScroll()
return
}
this.autoScrollFrame = raf(() => this.performAutoScroll())
},
stopAutoScroll() {
this.autoScrollDirection = 0
if (this.autoScrollFrame) {
const caf = typeof window !== 'undefined' ? window.cancelAnimationFrame : null
if (caf) {
caf(this.autoScrollFrame)
}
this.autoScrollFrame = null
}
},
removeDragListeners() {
if (this.dragListenersActive) {
if (typeof window !== 'undefined') {
window.removeEventListener('dragover', this.onGlobalDragOver)
}
this.dragListenersActive = false
}
},
async startExport() {
this.exporting = true
......@@ -229,7 +511,10 @@ export default {
},
computed: {
...mapGetters(['totalClipDuration', 'thumbnailedClips']),
...mapState(['clips', 'preview', 'scrollToClip', 'current_export', 'effects'])
...mapState(['clips', 'preview', 'scrollToClip', 'current_export', 'effects']),
isDragging() {
return !!this.draggingClip
}
},
watch: {
scrollToClip() {
......@@ -259,6 +544,8 @@ export default {
},
unmounted() {
this.setClips([])
this.stopAutoScroll()
this.removeDragListeners()
}
}
</script>
......@@ -275,6 +562,12 @@ export default {
cursor: pointer;
width: 100%;
}
.clip-row {
position: relative;
}
.drag-buffer {
height: 14px;
}
.clip-btn {
float: right;
}
......@@ -292,9 +585,37 @@ export default {
.menu-icon {
cursor: pointer;
}
.toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.toggle-icon.active {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.25);
}
.menu-icon:hover {
color: #0d6efd;
}
.drop-indicator {
position: absolute;
left: 6px;
right: 6px;
height: 4px;
background-color: #0d6efd;
border-radius: 4px;
box-shadow: 0 0 8px rgba(13, 110, 253, 0.5);
z-index: 2000;
}
.drop-indicator-top {
top: -2px;
}
.drop-indicator-bottom {
bottom: -2px;
}
.text-menu {
color: #ffffff;
position: absolute;
......
......@@ -348,19 +348,30 @@ export default createStore({
},
async moveClip({dispatch, getters}, payload) {
let local_clips = [...getters.thumbnailedClips]
let reordered_clips = local_clips
if (payload.current != payload.dest) {
reordered_clips = reorderArray(local_clips, payload.current, payload.dest)
if (!Array.isArray(local_clips) || local_clips.length === 0) {
return
}
if (payload.current === payload.dest || payload.current === undefined || payload.dest === undefined) {
return
}
let reordered_clips = reorderArray(local_clips, payload.current, payload.dest)
let pos = 0.0
for (let c of reordered_clips) {
c.position = pos
let edit_payload = { data: c, latest: false, thumbnail: false }
if (c.id == payload.clip.id) {
edit_payload.latest = true
let updates = []
const maxIndex = reordered_clips.length - 1
const affectedStart = Math.max(0, Math.min(payload.current, payload.dest))
const affectedEnd = Math.min(maxIndex, Math.max(payload.current, payload.dest))
for (let i = 0; i < reordered_clips.length; i++) {
let clip = reordered_clips[i]
let newPosition = pos
if (i >= affectedStart && i <= affectedEnd && clip.position !== newPosition) {
clip.position = newPosition
let edit_payload = { data: clip, latest: clip.id == payload.clip.id, thumbnail: false }
updates.push(edit_payload)
}
pos += (clip.end - clip.start)
}
for (let edit_payload of updates) {
dispatch('editClip', edit_payload)
pos += (c.end - c.start)
}
},
async deleteClip({commit}, clip_id) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment