diff --git a/src/css/mainwindow.css b/src/css/mainwindow.css index 569fb02c15..f12cb79d71 100644 --- a/src/css/mainwindow.css +++ b/src/css/mainwindow.css @@ -203,6 +203,29 @@ body::-webkit-scrollbar { animation-name: bounce; } +#grid-view-cursor { + position: relative; + top: 0; + left: 0; + margin-top: 7px; + + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 10px solid #999; + transition: left .1s ease-in; + animation-delay: 0s; + animation-duration: 0.5s; + animation-timing-function: ease-in; + animation-iteration-count: infinite; + animation-name: bounce; +} +#grid-cursor-container { + position: absolute; + z-index: 9999; +} + @keyframes bounce { 0% { top: 5px; @@ -217,7 +240,8 @@ body::-webkit-scrollbar { -#thumbnail-container .thumbnail * { +#thumbnail-container .thumbnail *, +.grid-view .thumbnail * { pointer-events: none; } @@ -248,13 +272,15 @@ body::-webkit-scrollbar { margin-right: 10px; } -#thumbnail-drawer .thumbnail.active { +#thumbnail-drawer .thumbnail.active, +.grid-view .thumbnail.active { background: #8d89cf; color: #000; transition: all 0s ease-out; } -#thumbnail-drawer .thumbnail.selected { +#thumbnail-drawer .thumbnail.selected, +.grid-view .thumbnail.selected { background: #8d89cf; color: #000; top: 0px; @@ -263,7 +289,8 @@ body::-webkit-scrollbar { box-shadow: none; z-index: 999; } -#thumbnail-drawer .thumbnail.selected.editing { +#thumbnail-drawer .thumbnail.selected.editing, +.grid-view .thumbnail.selected.editing { background: #999; top: -10px; position: relative; @@ -272,14 +299,16 @@ body::-webkit-scrollbar { box-shadow: 0px 10px 0px rgba(0,0,0,0.1); } -#thumbnail-drawer img { +#thumbnail-drawer img, +.grid-view img { background: white; border-radius: 3px; position: relative; box-shadow: none; } -#thumbnail-drawer img:after { +#thumbnail-drawer img:after, +.grid-view img:after { content: ""; position: absolute; z-index: 2; @@ -297,11 +326,13 @@ body::-webkit-scrollbar { flex-direction: row; } -#thumbnail-drawer .thumbnail .number { +#thumbnail-drawer .thumbnail .number, +.grid-view .thumbnail .number { font-weight: 700; } -#thumbnail-drawer .thumbnail .audio svg { +#thumbnail-drawer .thumbnail .audio svg, +.grid-view .thumbnail .audio svg { width: 14px; height: 14px; vertical-align: top; @@ -311,11 +342,13 @@ body::-webkit-scrollbar { margin-bottom: -3px; } -#thumbnail-drawer .thumbnail.selected .audio svg { +#thumbnail-drawer .thumbnail.selected .audio svg, +.grid-view .thumbnail.selected .audio svg { --color1: #000; } -#thumbnail-drawer .thumbnail .duration { +#thumbnail-drawer .thumbnail .duration, +.grid-view .thumbnail .duration { opacity: 0.8; } @@ -1004,3 +1037,52 @@ input[type=range]:focus::-webkit-slider-runnable-track { -webkit-user-drag: none; user-drag: none; } + +/* The grid view styles */ +.grid-view { + position: relative; + + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.3); + flex-shrink: 0; + width: 100%; + height: 100%; + padding: 10px; + overflow: auto; +} + +.grid-view .thumbnail { + flex: 1; + display: flex; + flex-direction: column; + background: #444; + padding: 5px; + font-size: 10px; + color: #888; + font-weight: 100; + box-shadow: 0 1px 1px rgba(0,0,0,0.2); + transition: all 0s ease-in-out; + align-content: center; + border-radius: 3px; +} + +.grid-view .thumbnail-container { + flex: 1; + display: flex; + flex-direction: column; + padding: 5px; + margin-right: 0px; + font-size: 10px; + align-content: center; +} + +.grid-row { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.grid-view .thumbnail .info { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/src/css/storyboarder-sketch-pane.css b/src/css/storyboarder-sketch-pane.css index d043c1fba8..42229c79c3 100644 --- a/src/css/storyboarder-sketch-pane.css +++ b/src/css/storyboarder-sketch-pane.css @@ -7,7 +7,6 @@ #storyboarder-sketch-pane { flex: 1; - display: flex; position: relative; width: 100%; height: 100%; diff --git a/src/js/utils/etags.js b/src/js/utils/etags.js new file mode 100644 index 0000000000..7047dc31b9 --- /dev/null +++ b/src/js/utils/etags.js @@ -0,0 +1,12 @@ +// TODO better name than etags? +// TODO store in boardData instead, but exclude from JSON? +// TODO use mtime trick like we do for layers and posterframes? +// cache buster for thumbnails +let etags = {} +const setEtag = absoluteFilePath => { etags[absoluteFilePath] = Date.now() } +const getEtag = absoluteFilePath => etags[absoluteFilePath] || '0' + +module.exports = { + setEtag, + getEtag +} \ No newline at end of file diff --git a/src/js/window/components/GridView/Grid.js b/src/js/window/components/GridView/Grid.js new file mode 100644 index 0000000000..7bec596787 --- /dev/null +++ b/src/js/window/components/GridView/Grid.js @@ -0,0 +1,124 @@ +// NOTE(): this is a clone of shot generator grid, with only difference that this is non jsx; +// the Storyboarder window doesn't support jsx yet. When main-window is refactored to react we should remove this file + +const h = require('../../../utils/h') + +// and just use the SG's one +const {useMemo, useEffect, useRef, useCallback, useState} = React = require('react') +const throttle = require('lodash.throttle') + +const getIndices = (container, itemHeight, numCols) => { + if (!container) { + return 0 + } + + //const first = Math.floor(container.scrollTop / itemHeight) * numCols + return Math.round((container.scrollTop + container.parentNode.clientHeight) / itemHeight) * numCols +} + +const getPlaceholder = (elementStyle, i, k, isLoading = false) => ( + ['div', + { + key:`grid-element-placeholder-${i + k}`, + style:elementStyle, + className:'thumbnail-container', + }, + {/*{isLoading &&
}*/} + ] +) + +const getComponent = (Component, itemData, elements, elementStyle, i, k) => ( + [Component, { + key:`grid-element-${i + k}`, + style:elementStyle, + index:i+k, + data:elements[i + k], + ...itemData + } + ] +) + +const Grid = React.memo(({ + Component, + elements, + + itemData, + + itemHeight = '100%', + itemWidth = '100%', + defaultElementWidth +}) => { + const elementStyle = { + height: itemHeight, + width: itemWidth + } + const [numCols, setNumCols] = useState(2) + const containerRef = useRef(null) + const [currentIndex, setCurrentIndex] = useState(getIndices()) + const resizeGrid = (e) => { + // NOTE(): Grid takes 70% of the space? + let gridSpace = window.innerWidth * 0.70 + let columnsNumber = Math.round(gridSpace / defaultElementWidth) + columnsNumber = columnsNumber < 2 ? 2 : columnsNumber + setNumCols(columnsNumber) + } + + useEffect(() => { + resizeGrid() + window.addEventListener('resize', resizeGrid); + return () => { + window.removeEventListener('resize', resizeGrid); + } + }, []) + + const onParentScroll = useCallback(throttle((e) => { + const index = getIndices(e.target, itemHeight, numCols) + if (index > currentIndex) { + setCurrentIndex(index) + } + }, 500), [currentIndex]) + + useEffect(() => { + if(!containerRef.current) return + containerRef.current.parentNode.addEventListener('scroll', onParentScroll) + const index = getIndices(containerRef.current, itemHeight, numCols) + if (index > currentIndex) { + setCurrentIndex(index) + } + + return () => { + if (containerRef.current) containerRef.current.parentNode.removeEventListener('scroll', onParentScroll) + } + }, [containerRef.current, currentIndex]) + + const components = useMemo(() => { + const result = [] + + for (let i = 0; i < elements.length; i += numCols) { + const row = [] + + for (let k = 0; k < numCols; k++) { + const index = i + k + if (elements[index]) { + row.push(getComponent(Component, itemData, elements, elementStyle, i, k)) + + } else { + row.push(getPlaceholder(elementStyle, i, k)) + } + } + + result.push( + ['div', { className:"grid-row", key:`row-${i}`}, row] + ) + } + + return result + }, [elements.length, itemData, currentIndex, numCols]) + + return h( + ['div', { ref:containerRef }, + components + ] + ) +}) +module.exports = Grid diff --git a/src/js/window/components/GridView/GridViewElement.js b/src/js/window/components/GridView/GridViewElement.js new file mode 100644 index 0000000000..b34b97bcf4 --- /dev/null +++ b/src/js/window/components/GridView/GridViewElement.js @@ -0,0 +1,100 @@ +const h = require('../../../utils/h') +const React = require('react') +const path = require('path') +const util = require('../../../utils/index') +const fs = require('fs-extra') +const { useEffect, useRef } = require('react') +const GridViewElement = React.memo(({ + data: board, + index, + boardPath, + boardModel, + boardData, + getEtag, + pointerDown, + pointerMove, + pointerLeave, + pointerEnter, + dblclick, + gridElementOffset, + selectThumbnail +}) => { + const thumbnailRef = useRef() + let defaultHeight = 200 + let thumbnailWidth = Math.floor(defaultHeight * boardData.aspectRatio) + const getImage = () => { + let imageFilename = path.join(boardPath, 'images', board.url.replace('.png', '-posterframe.jpg')) + let imageElement = '' + try { + if (fs.existsSync(imageFilename)) { + let src = imageFilename + '?' + getEtag(path.join(boardPath, 'images', boardModel.boardFilenameForThumbnail(board))) + imageElement = ['div', { className:"top"}, + ['img', { src:src, style:{ height:defaultHeight, width:thumbnailWidth }}] + ] + } else { + // blank image + imageElement = ['img', { src:"//:0", style: { height:"${defaultHeight}px", width:`${thumbnailWidth}px`}}] + } + } catch (err) { + } + return imageElement + } + + const getAudio = () => { + if (board.audio && board.audio.filename.length) { + return ['div', { className:"audio" }, + ['svg', + ['use', { 'xlink:href':"./img/symbol-defs.svg#icon-speaker-on"}] + ] + ] + } + } + const getBoardDialogue = () => { + if (board.dialogue) + return board.dialogue + return '' + } + + const getBoardDuration = () => { + if (board.duration) { + return util.msToTime(board.duration) + } else { + return util.msToTime(boardData.defaultBoardTiming) + } + } + + + useEffect(() => { + selectThumbnail(thumbnailRef.current) + }) + + return h( + ['div', { + className: "thumbnail-container", + style:{ width:thumbnailWidth + gridElementOffset }, + }, + ['div', { + style:{ display:"flex", flexDirection:"column", alignSelf:"center"}, + 'data-thumbnail':index, + 'data-type':"thumbnail-grid", + className:"thumbnail", + onPointerDown: pointerDown, + onPointerMove: pointerMove, + onPointerLeave: pointerLeave, + onPointerEnter: pointerEnter, + onDoubleClick: dblclick, + ref:thumbnailRef + }, + getImage(), + ['div', { className:"info" }, + ['div', { className:"number"}, board.shot], + getAudio(), + ['div', { className:"caption" }, getBoardDialogue()], + ['div', { className:"duration"}, getBoardDuration()] + ] + ] + ] + ) +}) + +module.exports = GridViewElement \ No newline at end of file diff --git a/src/js/window/components/GridView/index.js b/src/js/window/components/GridView/index.js new file mode 100644 index 0000000000..e924d6936a --- /dev/null +++ b/src/js/window/components/GridView/index.js @@ -0,0 +1,225 @@ +const ReactDOM = require('react-dom') +const h = require('../../../utils/h') +const Grid = require('./Grid') +const GridViewElement = require('./GridViewElement') +const { getEtag } = require('../../../utils/etags') +let enableEditModeDelay = 750 // msecs +class GridView { + constructor(boardData, boardPath, saveImageFile, getSelections, + getCurrentBoard, setCurrentBoard, getContextMenu, renderThumbnailDrawerSelections, + gotoBoard, gridDrag, setSketchPaneVisibility, boardModel, setEditorModeTimer, renderTimelineModeControlView) { + this.isEditMode = false + this.boardData = boardData + this.saveImageFile = saveImageFile + this.getSelections = getSelections + this.getCurrentBoard = getCurrentBoard + this.setCurrentBoard = setCurrentBoard + this.gridDrag = gridDrag + this.getContextMenu = getContextMenu + this.setSketchPaneVisibility = setSketchPaneVisibility + this.renderThumbnailDrawerSelections = renderThumbnailDrawerSelections + this.gotoBoard = gotoBoard + this.boardModel = boardModel + this.boardPath = boardPath + this.setEditorModeTimer = setEditorModeTimer + this.gridViewCursor = { + visible: false, + x: 0, + el: null + } + this.gridElementOffset = 24 + this.renderTimelineModeControlView = renderTimelineModeControlView + } + + get IsEditMode() { + return this.isEditMode + } + + set IsEditMode(value) { + this.isEditMode = value + } + + enableEditMode () { + this.isEditMode = true + this.gridViewCursor.visible = true + this.renderGridViewCursor() + this.renderThumbnailDrawerSelections() + this.getContextMenu().remove() + } + + getDefaultHeight() { + return 200 + this.gridElementOffset + } + + gridViewFromPoint(x, y, offset) { + if (!offset) { offset = 0 } + + let el = document.elementFromPoint(x-offset, y) + if(!el) return null + if(el.classList.contains('thumbnail-container')) { + el = el.childNodes[0] + } + if (!el.classList.contains('thumbnail')) return null + let selections = this.getSelections() + // if part of a multi-selection, base from right-most element + if (selections.has(Number(el.dataset.thumbnail))) { + // base from the right-most thumbnail in the selection + let rightMost = Math.max(...selections) + let rightMostEl = document.querySelector('.grid-view div[data-thumbnail="' + rightMost + '"]') + el = rightMostEl + } + + return el + } + + renderGridViewCursor() { + let el = document.querySelector('#grid-cursor-container') + if (el) { // shouldRenderThumbnailDrawer + let gridViewCursor = this.gridViewCursor + if (gridViewCursor.visible) { + el.style.display = '' + el.style.left = gridViewCursor.x + 'px' + el.style.top = gridViewCursor.y + 'px' + } else { + el.style.display = 'none' + el.style.left = '0px' + } + } + } + + cleanUpGridView(){ + let gridView = document.querySelector(".grid-view") + if(!gridView) return + gridView.removeEventListener('pointerdown', this.gridDrag) + ReactDOM.unmountComponentAtNode(gridView) + } + + updateGridViewCursor(x, y) { + + let el = this.gridViewFromPoint(x, y) + if (!el) return + + let rect = el.getBoundingClientRect() + + this.gridViewCursor.el = el + let elementOffsetX + + let elementOffsetY = rect.top + let arrowOffsetX + // Figures out if mouse is to the left side of container or to the right + let reactMiddlePointX = rect.left + rect.width / 2 + if(x < reactMiddlePointX) { + let cursor = document.querySelector('#grid-cursor-container') + elementOffsetX = rect.left + arrowOffsetX = -8 - cursor.clientWidth + this.gridViewCursor.side = "Left" + } else if(x >= reactMiddlePointX) { + elementOffsetX = rect.right + arrowOffsetX = 8 + this.gridViewCursor.side = "Right" + } + + let scrollTop = el.scrollTop + let arrowOffsetY = -8 + + this.gridViewCursor.x = elementOffsetX + arrowOffsetX + this.gridViewCursor.y = elementOffsetY + arrowOffsetY - scrollTop + } + + pointerEnter (e) { + let selections = this.getSelections() + let currentBoard = this.getCurrentBoard() + if (!this.isEditMode && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { + this.getContextMenu().attachTo(e.target) + } + } + + pointerLeave (e) { + if (e.relatedTarget instanceof Element && !this.getContextMenu().hasChild(e.relatedTarget)) { + this.getContextMenu().remove() + } + } + + pointerMove (e) { + let selections = this.getSelections() + let currentBoard = this.getCurrentBoard() + if (!this.isEditMode && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { + this.getContextMenu().attachTo(e.target) + } + } + + pointerDown (e) { + let selections = this.getSelections() + let currentBoard = this.getCurrentBoard() + if (!this.isEditMode && selections.size <= 1) this.getContextMenu().attachTo(e.target) + // always track cursor position + this.updateGridViewCursor(e.clientX, e.clientY) + + if (e.button === 0) { + this.setEditorModeTimer(setTimeout(() => this.enableEditMode(), enableEditModeDelay)) + } else { + + this.enableEditMode() + } + + let index = Number(e.target.dataset.thumbnail) + if (selections.has(index)) { + // ignore + } else if (currentBoard !== index) { + // go to board by index + // reset selections + selections.clear() + this.saveImageFile().then(() => { + this.setCurrentBoard(index) + this.renderThumbnailDrawerSelections() + this.gotoBoard(index) + }) + } + } + + doubleClick (e) { + this.setSketchPaneVisibility(true) + this.renderTimelineModeControlView({ show: true }) + } + + selectThumbnail(thumb) { + let i = Number(thumb.dataset.thumbnail) + if(i === this.getCurrentBoard()) { + thumb.classList.toggle('active', true) + thumb.classList.toggle('selected', this.getSelections().has(i)) + thumb.classList.toggle('editing', this.isEditMode) + } + } + + renderGridView (callback) { + this.cleanUpGridView() + let boardData = this.boardData + let gridView = document.querySelector('.grid-view') + let defaultHeight = this.getDefaultHeight() + let thumbnailWidth = Math.floor(defaultHeight * boardData.aspectRatio) + gridView.addEventListener('pointerdown', this.gridDrag) + ReactDOM.render(h([Grid, { + itemData:{ + boardData, + boardPath: this.boardPath, + boardModel: this.boardModel, + getEtag, + pointerDown: (e) => this.pointerDown(e), + pointerMove: (e) => this.pointerMove(e), + pointerLeave: (e) => this.pointerLeave(e), + pointerEnter: (e) => this.pointerEnter(e), + dblclick: (e) => this.doubleClick(e), + selectThumbnail: (thumb) => this.selectThumbnail(thumb) + }, + Component:GridViewElement, + elements:boardData.boards, + numCols:3, + itemHeight: defaultHeight, + defaultElementWidth: thumbnailWidth, + gridElementOffset: this.gridElementOffset + } + ]), gridView, () => { this.renderThumbnailDrawerSelections(), callback && callback() }) + } +} + +module.exports = GridView diff --git a/src/js/window/main-window.js b/src/js/window/main-window.js index ddb39d991f..8a1945168d 100644 --- a/src/js/window/main-window.js +++ b/src/js/window/main-window.js @@ -14,6 +14,7 @@ const isDev = require('electron-is-dev') const log = require('../shared/storyboarder-electron-log') log.catchErrors() const ReactDOM = require('react-dom') +const { useEffect } = require('react') const h = require('../utils/h') const ShotGeneratorPanel = require('./components/ShotGeneratorPanel') @@ -107,6 +108,8 @@ const translateCheckbox = (elementName, traslationKey) => { childNodes[childNodes.length - 1].textContent = i18n.t(traslationKey) } +const GridView = require("./components/GridView") +const { setEtag, getEtag } = require('../utils/etags') const translateTooltip = (elementName, traslationKey) => { let element = document.querySelector(elementName) if(!element) return @@ -275,8 +278,8 @@ let imageFileDirtyTimer let isSavingImageFile = false // lock for saveImageFile let drawIdleTimer - -let isEditMode = false +let editModeState = false +let isEditMode = () => editModeState || gridView.IsEditMode let editModeTimer let enableEditModeDelay = 750 // msecs let periodicDragUpdateTimer @@ -322,6 +325,8 @@ let dragPoint let dragTarget let scrollPoint +let gridView + // CAF cancel tokens for async functions let cancelTokens = {} @@ -333,14 +338,6 @@ const serial = funcs => funcs.reduce((promise, func) => promise.then(result => func().then(Array.prototype.concat.bind(result))), Promise.resolve([])) -// TODO better name than etags? -// TODO store in boardData instead, but exclude from JSON? -// TODO use mtime trick like we do for layers and posterframes? -// cache buster for thumbnails -let etags = {} -const setEtag = absoluteFilePath => { etags[absoluteFilePath] = Date.now() } -const getEtag = absoluteFilePath => etags[absoluteFilePath] || '0' - const cacheKey = filepath => { try { // file exists, cache based on mtime @@ -1222,7 +1219,7 @@ const loadBoardUI = async () => { clearTimeout(editModeTimer) } - if (isEditMode && dragMode) { + if (isEditMode() && dragMode) { // defer to periodicDragUpdate() return } @@ -1243,27 +1240,49 @@ const loadBoardUI = async () => { clearTimeout(editModeTimer) // log.info('pointerup', isEditMode) - if (isEditMode) { - let x = e.clientX, y = e.clientY - - // 1) try to find nearest thumbnail, otherwise, - // HACK 2) try to find last known thumbnail position - let el = thumbnailFromPoint(x, y) || thumbnailCursor.el - let offset = 0 - if (el) { - offset = el.getBoundingClientRect().width - el = thumbnailFromPoint(x, y, offset/2) - } + if (isEditMode()) { + let checkoutBoard + let index + if(gridView.IsEditMode) { + let el = gridView.gridViewFromPoint(gridView.gridViewCursor.x, gridView.gridViewCursor.y) || gridView.gridViewCursor.el + if (!el) { + log.warn("couldn't find nearest thumbnail") + return + } + if (el) { + if(gridView.gridViewCursor.side === "Left") { + index = Number(el.dataset.thumbnail) + } else { + index = Number(el.dataset.thumbnail) + 1 + } + } + checkoutBoard = () => gridView.renderGridView(() => gotoBoard(currentBoard, true)) - if (!el) { - log.warn("couldn't find nearest thumbnail") - } + } else { + let x = e.clientX, y = e.clientY - let index - if (isBeforeFirstThumbnail(x, y)) { - index = 0 - } else if (el) { - index = Number(el.dataset.thumbnail) + 1 + // 1) try to find nearest thumbnail, otherwise, + // HACK 2) try to find last known thumbnail position + let el = thumbnailFromPoint(x, y) || thumbnailCursor.el + + el = thumbnailFromPoint(x, y) || thumbnailCursor.el + let offset = 0 + if (el) { + offset = el.getBoundingClientRect().width + el = gridView.gridViewFromPoint(x, y, offset/2) + } + + if (!el) { + log.warn("couldn't find nearest thumbnail") + } + + if (isBeforeFirstThumbnail(x, y)) { + index = 0 + } else if (el) { + index = Number(el.dataset.thumbnail) + 1 + } + + checkoutBoard = () => gotoBoard(currentBoard, true) } if (!util.isUndefined(index)) { @@ -1276,13 +1295,14 @@ const loadBoardUI = async () => { } renderThumbnailDrawer() - gotoBoard(currentBoard, true) + + checkoutBoard() }) } else { log.info('could not find point for move operation') } - disableEditMode() + } }) @@ -2086,9 +2106,18 @@ const loadBoardUI = async () => { // for debugging: // // remote.getCurrentWebContents().openDevTools() -} + gridView = new GridView(boardData, boardPath, saveImageFile, getSelections, + getCurrentBoard, setCurrentBoard, getContextMenu, renderThumbnailDrawerSelections, + gotoBoard, gridDrag, setSketchPaneVisibility, boardModel, setEditorModeTimer, renderTimelineModeControlView) +} +const getSelections = () => selections +const getCurrentBoard = () => currentBoard +const setCurrentBoard = (board) => currentBoard = board +const getContextMenu = () => contextMenu +const setEditorModeTimer = (timer) => editModeTimer = timer + // whenever the scene changes const renderScene = async () => { audioPlayback.setSceneData(boardData) @@ -3436,6 +3465,61 @@ let goNextBoard = async (direction, shouldPreserveSelections = false) => { } } +const ScrollTypes = { + VERTICAL: "vertical", + HORIZONTAL: "horizontal" +} + +const isElementInViewport = (el, container) => { + + let rectElement = el.getBoundingClientRect() + let rectContainer = container.getBoundingClientRect() + return ( + rectElement.top >= rectContainer.top && + rectElement.left >= rectContainer.left && + rectElement.bottom <= rectContainer.bottom && + rectElement.right <= rectContainer.right + ); +} +let scrollInterval = {} +const smoothScroll = (currentPosition, desiredPosition, element, selectedElement, scrollType = ScrollTypes.HORIZONTAL) => { + let steps = 10 + let iteration = 0 + let offset = 10 + // Is in scroll area + if(isElementInViewport(selectedElement, element)) { + return + } + let padding = Number.parseInt(window.getComputedStyle(element, null).getPropertyValue('padding')) + let rectElement = selectedElement.getBoundingClientRect() + let rectContainer = element.getBoundingClientRect() + let difference + // Is Object higher + if( rectElement.top >= rectContainer.top && + rectElement.left >= rectContainer.left) { + difference = scrollType === ScrollTypes.HORIZONTAL ? rectElement.right - rectContainer.right : rectElement.bottom - rectContainer.bottom + difference += padding + offset + } + else { + difference = scrollType === ScrollTypes.HORIZONTAL ? rectElement.left - rectContainer.left : rectElement.top - rectContainer.top + difference -= padding - offset + } + let iterationStep = difference / steps + let interval = setInterval(() => { + if (currentPosition !== desiredPosition && iteration !== steps) { + if(scrollType === ScrollTypes.HORIZONTAL) { + element.scrollTo(currentPosition += iterationStep, 0); + } else { + element.scrollTo(0, currentPosition += iterationStep); + } + iteration ++; + } else { + clearInterval(interval); + } + }, 10); + return interval +} + let gotoBoard = (boardNumber, shouldPreserveSelections = false) => { if(isRecording && isRecordingStarted) { // make sure we capture the last frame @@ -3471,49 +3555,65 @@ let gotoBoard = (boardNumber, shouldPreserveSelections = false) => { // let shouldRenderThumbnailDrawer = false if (shouldRenderThumbnailDrawer) { + renderThumbnailDrawerSelections() for (var item of document.querySelectorAll('.thumbnail')) { item.classList.remove('active') } - let thumbDiv = document.querySelector(`[data-thumbnail='${currentBoard}']`) - if (thumbDiv) { - thumbDiv.classList.add('active') - thumbDiv.scrollIntoView() - - let thumbL = thumbDiv.offsetLeft - let thumbR = thumbDiv.offsetLeft + thumbDiv.offsetWidth - - let containerDiv = document.querySelector('#thumbnail-container') - let containerL = containerDiv.scrollLeft - let containerR = containerDiv.scrollLeft + containerDiv.offsetWidth - - if (thumbR >= containerR) { - // if right side of thumbnail is beyond the right edge of the visible container - // scroll the visible container - // to reveal up to the right edge of the thumbnail - containerDiv.scrollLeft = (thumbL - containerDiv.offsetWidth) + thumbDiv.offsetWidth + 100 - } else if (containerL >= thumbL) { - // if left side of thumbnail is beyond the left edge of the visible container - // scroll the visible container - // to reveal up to the left edge of the thumbnail - containerDiv.scrollLeft = thumbL - 50 + let thumbDivs = document.querySelectorAll(`[data-thumbnail='${currentBoard}']`) + for(let i = 0; i < thumbDivs.length; i++) { + let thumbDiv = thumbDivs[i] + if(thumbDiv.dataset.type === 'thumbnail-grid') { + thumbDiv.classList.add('active') + let containerDiv = document.querySelector('.grid-view') + let rowContainer = thumbDiv.parentNode.parentNode + if(scrollInterval.interval && !scrollInterval.parentNode.isSameNode(rowContainer)) clearInterval(scrollInterval) + + if(!scrollInterval.parentNode || !scrollInterval.parentNode.isSameNode(rowContainer)) { + scrollInterval.interval = smoothScroll(containerDiv.scrollTop, thumbDiv.parentNode.offsetTop, containerDiv, thumbDiv, ScrollTypes.VERTICAL) + scrollInterval.parentNode = rowContainer + } + } else { + if (thumbDiv) { + thumbDiv.classList.add('active') + let containerDiv = document.querySelector('#thumbnail-container') + thumbDiv.scrollIntoView() + let thumbL = thumbDiv.offsetLeft + let thumbR = thumbDiv.offsetLeft + thumbDiv.offsetWidth + + let containerL = containerDiv.scrollLeft + let containerR = containerDiv.scrollLeft + containerDiv.offsetWidth + + if (thumbR >= containerR) { + // if right side of thumbnail is beyond the right edge of the visible container + // scroll the visible container + // to reveal up to the right edge of the thumbnail + containerDiv.scrollLeft = (thumbL - containerDiv.offsetWidth) + thumbDiv.offsetWidth + 100 + } else if (containerL >= thumbL) { + // if left side of thumbnail is beyond the left edge of the visible container + // scroll the visible container + // to reveal up to the left edge of the thumbnail + containerDiv.scrollLeft = thumbL - 50 + } + } else { + // + // TODO when would this happen? + // + // wait for render, then update + setTimeout( + n => { + let newThumb = document.querySelector(`[data-thumbnail='${n}']`) + newThumb.classList.add('active') + newThumb.scrollIntoView() + }, + 10, + currentBoard + ) + } } - } else { - // - // TODO when would this happen? - // - // wait for render, then update - setTimeout( - n => { - let newThumb = document.querySelector(`[data-thumbnail='${n}']`) - newThumb.classList.add('active') - newThumb.scrollIntoView() - }, - 10, - currentBoard - ) } + } renderMetaData() @@ -4039,13 +4139,11 @@ let renderThumbnailDrawerSelections = () => { renderSceneTimeline() let thumbnails = document.querySelectorAll('.thumbnail') - for (let thumb of thumbnails) { let i = Number(thumb.dataset.thumbnail) - thumb.classList.toggle('active', currentBoard == i) thumb.classList.toggle('selected', selections.has(i)) - thumb.classList.toggle('editing', isEditMode) + thumb.classList.toggle('editing', isEditMode()) } } @@ -4095,6 +4193,7 @@ const renderSceneTimeline = () => { let renderThumbnailDrawer = () => { updateSceneTiming() + isGridViewMode && gridView.renderGridView() // reflect the current view cycleViewMode(0) @@ -4167,10 +4266,10 @@ let renderThumbnailDrawer = () => { html.push('') i++ } - document.querySelector('#thumbnail-drawer').innerHTML = html.join('') + let thumbnailDrawer = document.querySelector('#thumbnail-drawer') + thumbnailDrawer.innerHTML = html.join('') renderThumbnailButtons() - renderThumbnailDrawerSelections() if (!contextMenu) { @@ -4230,10 +4329,10 @@ let renderThumbnailDrawer = () => { }) } - let thumbnails = document.querySelectorAll('.thumbnail') + let thumbnails = thumbnailDrawer.querySelectorAll('.thumbnail') for (var thumb of thumbnails) { thumb.addEventListener('pointerenter', (e) => { - if (!isEditMode && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { + if (!isEditMode() && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { contextMenu.attachTo(e.target) } }) @@ -4243,13 +4342,13 @@ let renderThumbnailDrawer = () => { } }) thumb.addEventListener('pointermove', (e) => { - if (!isEditMode && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { + if (!isEditMode() && selections.size <= 1 && e.target.dataset.thumbnail === currentBoard) { contextMenu.attachTo(e.target) } }) thumb.addEventListener('pointerdown', (e) => { log.info('DOWN') - if (!isEditMode && selections.size <= 1) contextMenu.attachTo(e.target) + if (!isEditMode() && selections.size <= 1) contextMenu.attachTo(e.target) // always track cursor position updateThumbnailCursor(e.clientX, e.clientY) @@ -4273,7 +4372,6 @@ let renderThumbnailDrawer = () => { let min = Math.min(...selections, index) let max = Math.max(...selections, index) selections = new Set(util.range(min, max)) - renderThumbnailDrawerSelections() } else if (currentBoard !== index) { // go to board by index @@ -4562,15 +4660,7 @@ let getSceneColor = function (sceneString) { return ('black') } -let setDragTarget = (x) => { - let containerRect = dragTarget.getBoundingClientRect() - - let mouseX = x - containerRect.left - let midpointX = containerRect.width / 2 - - // distance ratio -1...0...1 - let distance = (mouseX - midpointX) / midpointX - +const getScrollingStrength = (distance) => { // default is the dead zone at 0 let strength = 0 // -1..-0.5 @@ -4585,26 +4675,53 @@ let setDragTarget = (x) => { } strength = util.clamp(strength, -1, 1) + return strength +} + +let setScroll = (point, target, elementSize, sideName, sizeName, scrollDirection) => { + let containerRect = target.getBoundingClientRect() + let mousePos = point - containerRect[sideName] + let midpointPos = containerRect[sizeName] / 2 + + // distance ratio -1...0...1 + let distance = (mousePos - midpointPos) / midpointPos + + let strength = getScrollingStrength(distance) // max speed is half of the average board width per pointermove - let speedlimit = Math.floor(60 * boardData.aspectRatio * 0.5) + let speedlimit = Math.floor(elementSize * boardData.aspectRatio * 0.5) // NOTE I don't bother clamping min/max because scrollLeft handles that for us - let newScrollLeft = dragTarget.scrollLeft + (strength * speedlimit) + let newScroll = target[scrollDirection] + (strength * speedlimit) - dragTarget.scrollLeft = newScrollLeft + target[scrollDirection] = newScroll } +let scrollTarget = ({x = null, y = null}, target, elementSize) => { + if(x) { + setScroll(x, target, elementSize, 'left', 'width', 'scrollLeft') + } + if(y) { + setScroll(y, target, elementSize, 'top', 'height', 'scrollTop') + } + +} + + let updateDrag = () => { if (util.isUndefined(lastPointer.x) || util.isUndefined(lastPointer.y)) { return } - if (isEditMode && dragMode) { - setDragTarget(lastPointer.x) + if (isEditMode() && dragMode) { + + !gridView.IsEditMode ? scrollTarget({x:lastPointer.x}, dragTarget, 60) : + scrollTarget({y:lastPointer.y}, document.querySelector('.grid-view'), gridView.getDefaultHeight()) updateThumbnailCursor(lastPointer.x, lastPointer.y) renderThumbnailCursor() + gridView.IsEditMode && gridView.updateGridViewCursor(lastPointer.x, lastPointer.y) + gridView.IsEditMode && gridView.renderGridViewCursor() } } @@ -4899,7 +5016,7 @@ window.onkeydown = (e) => { if (isCommandPressed('drawing:exit-current-mode')) { e.preventDefault() - if (dragMode && isEditMode && selections.size) { + if (dragMode && isEditMode() && selections.size) { disableEditMode() disableDragMode() } else { @@ -6170,8 +6287,8 @@ let reorderBoardsRight = () => { } let enableEditMode = () => { - if (!isEditMode && selections.size) { - isEditMode = true + if (!isEditMode() && selections.size) { + editModeState = true thumbnailCursor.visible = true renderThumbnailCursor() renderThumbnailDrawerSelections() @@ -6183,12 +6300,15 @@ let enableEditMode = () => { } let disableEditMode = () => { - if (isEditMode) { + if (isEditMode()) { sfx.playEffect('metal') sfx.negative() - isEditMode = false + editModeState = false thumbnailCursor.visible = false + gridView.gridViewCursor.visible = false + gridView.IsEditMode = false renderThumbnailCursor() + isGridViewMode && gridView.renderGridViewCursor() renderThumbnailDrawerSelections() } } @@ -6196,7 +6316,6 @@ let disableEditMode = () => { let thumbnailFromPoint = (x, y, offset) => { if (!offset) { offset = 0 } let el = document.elementFromPoint(x-offset, y) - if (!el || !el.classList.contains('thumbnail')) return null // if part of a multi-selection, base from right-most element @@ -6681,16 +6800,68 @@ const updateSceneFromScript = async () => { renderScript() } +const setSketchPaneVisibility = (isVisible) => { + let storyboarderSketchPane = document.querySelector("#storyboarder-sketch-pane") + let gridViewElement = document.querySelector('.grid-view') + let container = storyboarderSketchPane.getElementsByClassName("container")[0] + if(isVisible) { + isGridViewMode = false + container.style["position"] = "relative" + container.style["left"] = "0px" + gridViewElement.style["position"] = "absolute" + gridViewElement.style["left"] = "-99999px" + gridView.cleanUpGridView() + // NOTE(): A hackish way to update sketchpane + gotoBoard(getCurrentBoard()) + } else { + isGridViewMode = true + container.style["position"] = "absolute" + container.style["left"] = "-99999px" + gridViewElement.style["position"] = "relative" + gridViewElement.style["left"] = "0px" + } +} + +//#region Grid view + +const gridDrag = (e)=>{ + if (e.pointerType == 'pen' || e.pointerType == 'mouse') { + dragTarget = document.querySelector('.grid-view') + dragTarget.style.overflow = 'hidden' + dragTarget.style.scrollBehavior = 'unset' + dragMode = true + dragPoint = [e.pageX, e.pageY] + scrollPoint = [dragTarget.scrollLeft, dragTarget.scrollTop] + periodicDragUpdate() + } +} + +// TODO(): Find a better way to switch modes +let isGridViewMode = false +//#endregion const TimelineModeControlView = ({ mode = 'sequence', show = false }) => { let style = { display: show ? 'flex' : 'none' } + useEffect(() => { + setSketchPaneVisibility(true) + }, []) + const onBoardsSelect = () => { shouldRenderThumbnailDrawer = false + setSketchPaneVisibility(true) renderThumbnailDrawer() } const onTimelineSelect = () => { shouldRenderThumbnailDrawer = true + setSketchPaneVisibility(true) + renderThumbnailDrawer() + } + const onGridViewSelect = () => { + shouldRenderThumbnailDrawer = true + setSketchPaneVisibility(false) renderThumbnailDrawer() + gridView.renderGridView() + gotoBoard(getCurrentBoard()) } return h( @@ -6706,19 +6877,29 @@ const TimelineModeControlView = ({ mode = 'sequence', show = false }) => { ], ['div.spacer'], ['div.btn', { - className: mode !== 'sequence' ? 'selected' : null, + className: mode === 'time' ? 'selected' : null, onPointerUp: onBoardsSelect }, ['svg', { className: 'icon' }, ['use', { xlinkHref: './img/symbol-defs.svg#timeline-timeline' }] ], ['span', 'Timeline'] + ], + ['div.spacer'], + ['div.btn', { + className: mode === 'grid-view' ? 'selected' : null, + onPointerUp: onGridViewSelect + }, + ['svg', { className: 'icon' }, + ['use', { xlinkHref: './img/symbol-defs.svg#timeline-timeline' }] + ], + ['span', 'Grid View'] ] ] ) } const renderTimelineModeControlView = ({ show = false }) => { - let mode = shouldRenderThumbnailDrawer ? 'sequence' : 'time' + let mode = !shouldRenderThumbnailDrawer ? 'time' : isGridViewMode ? 'grid-view' : 'sequence' ReactDOM.render( h([TimelineModeControlView, { mode, show }]), document.getElementById('timeline-mode-control-view') diff --git a/src/main-window.html b/src/main-window.html index 36f08e20e3..d478445fb7 100644 --- a/src/main-window.html +++ b/src/main-window.html @@ -256,7 +256,10 @@