diff options
Diffstat (limited to 'app/assets/javascripts/image_diff')
10 files changed, 539 insertions, 0 deletions
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js new file mode 100644 index 00000000000..6a6a668308d --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js @@ -0,0 +1,38 @@ +export function createImageBadge(noteId, { x, y }, classNames = []) { + const buttonEl = document.createElement('button'); + const classList = classNames.concat(['js-image-badge']); + classList.forEach(className => buttonEl.classList.add(className)); + buttonEl.setAttribute('type', 'button'); + buttonEl.setAttribute('disabled', true); + buttonEl.dataset.noteId = noteId; + buttonEl.style.left = `${x}px`; + buttonEl.style.top = `${y}px`; + + return buttonEl; +} + +export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { + const buttonEl = createImageBadge(noteId, coordinate, ['badge']); + buttonEl.innerText = badgeText; + + containerEl.appendChild(buttonEl); +} + +export function addImageCommentBadge(containerEl, { coordinate, noteId }) { + const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge', 'inverted']); + const iconEl = document.createElement('i'); + iconEl.className = 'fa fa-comment-o'; + iconEl.setAttribute('aria-label', 'comment'); + + buttonEl.appendChild(iconEl); + containerEl.appendChild(buttonEl); +} + +export function addAvatarBadge(el, event) { + const { noteId, badgeNumber } = event.detail; + + // Add badge to new comment + const avatarBadgeEl = el.querySelector(`#${noteId} .badge`); + avatarBadgeEl.innerText = badgeNumber; + avatarBadgeEl.classList.remove('hidden'); +} diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js new file mode 100644 index 00000000000..05000c73052 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js @@ -0,0 +1,58 @@ +export function addCommentIndicator(containerEl, { x, y }) { + const buttonEl = document.createElement('button'); + buttonEl.classList.add('btn-transparent'); + buttonEl.classList.add('comment-indicator'); + buttonEl.setAttribute('type', 'button'); + buttonEl.style.left = `${x}px`; + buttonEl.style.top = `${y}px`; + + buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark'); + + containerEl.appendChild(buttonEl); +} + +export function removeCommentIndicator(imageFrameEl) { + const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); + const imageEl = imageFrameEl.querySelector('img'); + const willRemove = !!commentIndicatorEl; + let meta = {}; + + if (willRemove) { + meta = { + x: parseInt(commentIndicatorEl.style.left, 10), + y: parseInt(commentIndicatorEl.style.top, 10), + image: { + width: imageEl.width, + height: imageEl.height, + }, + }; + + commentIndicatorEl.remove(); + } + + return Object.assign({}, meta, { + removed: willRemove, + }); +} + +export function showCommentIndicator(imageFrameEl, coordinate) { + const { x, y } = coordinate; + const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator'); + + if (commentIndicatorEl) { + commentIndicatorEl.style.left = `${x}px`; + commentIndicatorEl.style.top = `${y}px`; + } else { + addCommentIndicator(imageFrameEl, coordinate); + } +} + +export function commentIndicatorOnClick(event) { + // Prevent from triggering onAddImageDiffNote in notes.js + event.stopPropagation(); + + const buttonEl = event.currentTarget; + const diffViewerEl = buttonEl.closest('.diff-viewer'); + const textareaEl = diffViewerEl.querySelector('.note-container .note-textarea'); + textareaEl.focus(); +} diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js new file mode 100644 index 00000000000..12d56714b34 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js @@ -0,0 +1,44 @@ +export function setPositionDataAttribute(el, options) { + // Update position data attribute so that the + // new comment form can use this data for ajax request + const { x, y, width, height } = options; + const position = el.dataset.position; + const positionObject = Object.assign({}, JSON.parse(position), { + x, + y, + width, + height, + }); + + el.setAttribute('data-position', JSON.stringify(positionObject)); +} + +export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) { + const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge'); + avatarBadgeEl.innerText = newBadgeNumber; +} + +export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) { + const discussionBadgeEl = discussionEl.querySelector('.badge'); + discussionBadgeEl.innerText = newBadgeNumber; +} + +export function toggleCollapsed(event) { + const toggleButtonEl = event.currentTarget; + const discussionNotesEl = toggleButtonEl.closest('.discussion-notes'); + const formEl = discussionNotesEl.querySelector('.discussion-form'); + const isCollapsed = discussionNotesEl.classList.contains('collapsed'); + + if (isCollapsed) { + discussionNotesEl.classList.remove('collapsed'); + } else { + discussionNotesEl.classList.add('collapsed'); + } + + // Override the inline display style set in notes.js + if (formEl && !isCollapsed) { + formEl.style.display = 'none'; + } else if (formEl && isCollapsed) { + formEl.style.display = 'block'; + } +} diff --git a/app/assets/javascripts/image_diff/helpers/index.js b/app/assets/javascripts/image_diff/helpers/index.js new file mode 100644 index 00000000000..4a100631003 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/index.js @@ -0,0 +1,25 @@ +import * as badgeHelper from './badge_helper'; +import * as commentIndicatorHelper from './comment_indicator_helper'; +import * as domHelper from './dom_helper'; +import * as utilsHelper from './utils_helper'; + +export default { + addCommentIndicator: commentIndicatorHelper.addCommentIndicator, + removeCommentIndicator: commentIndicatorHelper.removeCommentIndicator, + showCommentIndicator: commentIndicatorHelper.showCommentIndicator, + commentIndicatorOnClick: commentIndicatorHelper.commentIndicatorOnClick, + + addImageBadge: badgeHelper.addImageBadge, + addImageCommentBadge: badgeHelper.addImageCommentBadge, + addAvatarBadge: badgeHelper.addAvatarBadge, + + setPositionDataAttribute: domHelper.setPositionDataAttribute, + updateDiscussionAvatarBadgeNumber: domHelper.updateDiscussionAvatarBadgeNumber, + updateDiscussionBadgeNumber: domHelper.updateDiscussionBadgeNumber, + toggleCollapsed: domHelper.toggleCollapsed, + + resizeCoordinatesToImageElement: utilsHelper.resizeCoordinatesToImageElement, + generateBadgeFromDiscussionDOM: utilsHelper.generateBadgeFromDiscussionDOM, + getTargetSelection: utilsHelper.getTargetSelection, + initImageDiff: utilsHelper.initImageDiff, +}; diff --git a/app/assets/javascripts/image_diff/helpers/utils_helper.js b/app/assets/javascripts/image_diff/helpers/utils_helper.js new file mode 100644 index 00000000000..96fc735e629 --- /dev/null +++ b/app/assets/javascripts/image_diff/helpers/utils_helper.js @@ -0,0 +1,95 @@ +import ImageBadge from '../image_badge'; +import ImageDiff from '../image_diff'; +import ReplacedImageDiff from '../replaced_image_diff'; +import '../../commit/image_file'; + +export function resizeCoordinatesToImageElement(imageEl, meta) { + const { x, y, width, height } = meta; + + const imageWidth = imageEl.width; + const imageHeight = imageEl.height; + + const widthRatio = imageWidth / width; + const heightRatio = imageHeight / height; + + return { + x: Math.round(x * widthRatio), + y: Math.round(y * heightRatio), + width: imageWidth, + height: imageHeight, + }; +} + +export function generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl) { + const position = JSON.parse(discussionEl.dataset.position); + const firstNoteEl = discussionEl.querySelector('.note'); + const badge = new ImageBadge({ + actual: position, + imageEl: imageFrameEl.querySelector('img'), + noteId: firstNoteEl.id, + discussionId: discussionEl.dataset.discussionId, + }); + + return badge; +} + +export function getTargetSelection(event) { + const containerEl = event.currentTarget; + const imageEl = containerEl.querySelector('img'); + + const x = event.offsetX; + const y = event.offsetY; + + const width = imageEl.width; + const height = imageEl.height; + + const actualWidth = imageEl.naturalWidth; + const actualHeight = imageEl.naturalHeight; + + const widthRatio = actualWidth / width; + const heightRatio = actualHeight / height; + + // Browser will include the frame as a clickable target, + // which would result in potential 1px out of bounds value + // This bound the coordinates to inside the frame + const normalizedX = Math.max(0, x) && Math.min(x, width); + const normalizedY = Math.max(0, y) && Math.min(y, height); + + return { + browser: { + x: normalizedX, + y: normalizedY, + width, + height, + }, + actual: { + // Round x, y so that we don't need to deal with decimals + x: Math.round(normalizedX * widthRatio), + y: Math.round(normalizedY * heightRatio), + width: actualWidth, + height: actualHeight, + }, + }; +} + +export function initImageDiff(fileEl, canCreateNote, renderCommentBadge) { + const options = { + canCreateNote, + renderCommentBadge, + }; + let diff; + + // ImageFile needs to be invoked before initImageDiff so that badges + // can mount to the correct location + new gl.ImageFile(fileEl); // eslint-disable-line no-new + + if (fileEl.querySelector('.diff-file .js-single-image')) { + diff = new ImageDiff(fileEl, options); + diff.init(); + } else if (fileEl.querySelector('.diff-file .js-replaced-image')) { + diff = new ReplacedImageDiff(fileEl, options); + diff.init(); + } + + return diff; +} diff --git a/app/assets/javascripts/image_diff/image_badge.js b/app/assets/javascripts/image_diff/image_badge.js new file mode 100644 index 00000000000..51a8cda98d7 --- /dev/null +++ b/app/assets/javascripts/image_diff/image_badge.js @@ -0,0 +1,23 @@ +import imageDiffHelper from './helpers/index'; + +const defaultMeta = { + x: 0, + y: 0, + width: 0, + height: 0, +}; + +export default class ImageBadge { + constructor(options) { + const { noteId, discussionId } = options; + + this.actual = options.actual || defaultMeta; + this.browser = options.browser || defaultMeta; + this.noteId = noteId; + this.discussionId = discussionId; + + if (options.imageEl && !options.browser) { + this.browser = imageDiffHelper.resizeCoordinatesToImageElement(options.imageEl, this.actual); + } + } +} diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js new file mode 100644 index 00000000000..f3af92cf2b0 --- /dev/null +++ b/app/assets/javascripts/image_diff/image_diff.js @@ -0,0 +1,143 @@ +import imageDiffHelper from './helpers/index'; +import ImageBadge from './image_badge'; +import { isImageLoaded } from '../lib/utils/image_utility'; + +export default class ImageDiff { + constructor(el, options) { + this.el = el; + this.canCreateNote = !!(options && options.canCreateNote); + this.renderCommentBadge = !!(options && options.renderCommentBadge); + this.$noteContainer = $('.note-container', this.el); + this.imageBadges = []; + } + + init() { + this.imageFrameEl = this.el.querySelector('.diff-file .js-image-frame'); + this.imageEl = this.imageFrameEl.querySelector('img'); + + this.bindEvents(); + } + + bindEvents() { + this.imageClickedWrapper = this.imageClicked.bind(this); + this.imageBlurredWrapper = imageDiffHelper.removeCommentIndicator.bind(null, this.imageFrameEl); + this.addBadgeWrapper = this.addBadge.bind(this); + this.removeBadgeWrapper = this.removeBadge.bind(this); + this.renderBadgesWrapper = this.renderBadges.bind(this); + + // Render badges + if (isImageLoaded(this.imageEl)) { + this.renderBadges(); + } else { + this.imageEl.addEventListener('load', this.renderBadgesWrapper); + } + + // jquery makes the event delegation here much simpler + this.$noteContainer.on('click', '.js-diff-notes-toggle', imageDiffHelper.toggleCollapsed); + $(this.el).on('click', '.comment-indicator', imageDiffHelper.commentIndicatorOnClick); + + if (this.canCreateNote) { + this.el.addEventListener('click.imageDiff', this.imageClickedWrapper); + this.el.addEventListener('blur.imageDiff', this.imageBlurredWrapper); + this.el.addEventListener('addBadge.imageDiff', this.addBadgeWrapper); + this.el.addEventListener('removeBadge.imageDiff', this.removeBadgeWrapper); + } + } + + imageClicked(event) { + const customEvent = event.detail; + const selection = imageDiffHelper.getTargetSelection(customEvent); + const el = customEvent.currentTarget; + + imageDiffHelper.setPositionDataAttribute(el, selection.actual); + imageDiffHelper.showCommentIndicator(this.imageFrameEl, selection.browser); + } + + renderBadges() { + const discussionsEls = this.el.querySelectorAll('.note-container .discussion-notes .notes'); + [...discussionsEls].forEach(this.renderBadge.bind(this)); + } + + renderBadge(discussionEl, index) { + const imageBadge = imageDiffHelper + .generateBadgeFromDiscussionDOM(this.imageFrameEl, discussionEl); + + this.imageBadges.push(imageBadge); + + const options = { + coordinate: imageBadge.browser, + noteId: imageBadge.noteId, + }; + + if (this.renderCommentBadge) { + imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options); + } else { + const numberBadgeOptions = Object.assign({}, options, { + badgeText: index + 1, + }); + + imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions); + } + } + + addBadge(event) { + const { x, y, width, height, noteId, discussionId } = event.detail; + const badgeText = this.imageBadges.length + 1; + const imageBadge = new ImageBadge({ + actual: { + x, + y, + width, + height, + }, + imageEl: this.imageFrameEl.querySelector('img'), + noteId, + discussionId, + }); + + this.imageBadges.push(imageBadge); + + imageDiffHelper.addImageBadge(this.imageFrameEl, { + coordinate: imageBadge.browser, + badgeText, + noteId, + }); + + imageDiffHelper.addAvatarBadge(this.el, { + detail: { + noteId, + badgeNumber: badgeText, + }, + }); + + const discussionEl = this.el.querySelector(`#discussion_${discussionId}`); + imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, badgeText); + } + + removeBadge(event) { + const { badgeNumber } = event.detail; + const indexToRemove = badgeNumber - 1; + const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge'); + + if (this.imageBadges.length !== badgeNumber) { + // Cascade badges count numbers for (avatar badges + image badges) + this.imageBadges.forEach((badge, index) => { + if (index > indexToRemove) { + const { discussionId } = badge; + const updatedBadgeNumber = index; + const discussionEl = this.el.querySelector(`#discussion_${discussionId}`); + + imageBadgeEls[index].innerText = updatedBadgeNumber; + + imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber); + imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber); + } + }); + } + + this.imageBadges.splice(indexToRemove, 1); + + const imageBadgeEl = imageBadgeEls[indexToRemove]; + imageBadgeEl.remove(); + } +} diff --git a/app/assets/javascripts/image_diff/init_discussion_tab.js b/app/assets/javascripts/image_diff/init_discussion_tab.js new file mode 100644 index 00000000000..2f16c6ef115 --- /dev/null +++ b/app/assets/javascripts/image_diff/init_discussion_tab.js @@ -0,0 +1,12 @@ +import imageDiffHelper from './helpers/index'; + +export default () => { + // Always pass can-create-note as false because a user + // cannot place new badge markers on discussion tab + const canCreateNote = false; + const renderCommentBadge = true; + + const diffFileEls = document.querySelectorAll('.timeline-content .diff-file.js-image-file'); + [...diffFileEls].forEach(diffFileEl => + imageDiffHelper.initImageDiff(diffFileEl, canCreateNote, renderCommentBadge)); +}; diff --git a/app/assets/javascripts/image_diff/replaced_image_diff.js b/app/assets/javascripts/image_diff/replaced_image_diff.js new file mode 100644 index 00000000000..4abd13fb472 --- /dev/null +++ b/app/assets/javascripts/image_diff/replaced_image_diff.js @@ -0,0 +1,92 @@ +import imageDiffHelper from './helpers/index'; +import { viewTypes, isValidViewType } from './view_types'; +import ImageDiff from './image_diff'; + +export default class ReplacedImageDiff extends ImageDiff { + init(defaultViewType = viewTypes.TWO_UP) { + this.imageFrameEls = { + [viewTypes.TWO_UP]: this.el.querySelector('.two-up .js-image-frame'), + [viewTypes.SWIPE]: this.el.querySelector('.swipe .js-image-frame'), + [viewTypes.ONION_SKIN]: this.el.querySelector('.onion-skin .js-image-frame'), + }; + + const viewModesEl = this.el.querySelector('.view-modes-menu'); + this.viewModesEls = { + [viewTypes.TWO_UP]: viewModesEl.querySelector('.two-up'), + [viewTypes.SWIPE]: viewModesEl.querySelector('.swipe'), + [viewTypes.ONION_SKIN]: viewModesEl.querySelector('.onion-skin'), + }; + + this.currentView = defaultViewType; + this.generateImageEls(); + this.bindEvents(); + } + + generateImageEls() { + this.imageEls = {}; + + const viewTypeNames = Object.getOwnPropertyNames(viewTypes); + viewTypeNames.forEach((viewType) => { + this.imageEls[viewType] = this.imageFrameEls[viewType].querySelector('img'); + }); + } + + bindEvents() { + super.bindEvents(); + + this.changeToViewTwoUp = this.changeView.bind(this, viewTypes.TWO_UP); + this.changeToViewSwipe = this.changeView.bind(this, viewTypes.SWIPE); + this.changeToViewOnionSkin = this.changeView.bind(this, viewTypes.ONION_SKIN); + + this.viewModesEls[viewTypes.TWO_UP].addEventListener('click', this.changeToViewTwoUp); + this.viewModesEls[viewTypes.SWIPE].addEventListener('click', this.changeToViewSwipe); + this.viewModesEls[viewTypes.ONION_SKIN].addEventListener('click', this.changeToViewOnionSkin); + } + + get imageEl() { + return this.imageEls[this.currentView]; + } + + get imageFrameEl() { + return this.imageFrameEls[this.currentView]; + } + + changeView(newView) { + if (!isValidViewType(newView)) { + return; + } + + const indicator = imageDiffHelper.removeCommentIndicator(this.imageFrameEl); + + this.currentView = newView; + + // Clear existing badges on new view + const existingBadges = this.imageFrameEl.querySelectorAll('.badge'); + [...existingBadges].map(badge => badge.remove()); + + // Remove existing references to old view image badges + this.imageBadges = []; + + // Image_file.js has a fade animation of 200ms for loading the view + // Need to wait an additional 250ms for the images to be displayed + // on window in order to re-normalize their dimensions + setTimeout(this.renderNewView.bind(this, indicator), 250); + } + + renderNewView(indicator) { + // Generate badge coordinates on new view + this.renderBadges(); + + // Re-render indicator in new view + if (indicator.removed) { + const normalizedIndicator = imageDiffHelper + .resizeCoordinatesToImageElement(this.imageEl, { + x: indicator.x, + y: indicator.y, + width: indicator.image.width, + height: indicator.image.height, + }); + imageDiffHelper.showCommentIndicator(this.imageFrameEl, normalizedIndicator); + } + } +} diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js new file mode 100644 index 00000000000..ab0a595571f --- /dev/null +++ b/app/assets/javascripts/image_diff/view_types.js @@ -0,0 +1,9 @@ +export const viewTypes = { + TWO_UP: 'TWO_UP', + SWIPE: 'SWIPE', + ONION_SKIN: 'ONION_SKIN', +}; + +export function isValidViewType(validate) { + return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate); +} |