Added initial Before/After card UI

refs https://github.com/TryGhost/Team/issues/1249

This adds a WIP for the Before/After card UI - behind an alpha flag.
It's completely missing design input and is intented to serve as a base
to work from.
This commit is contained in:
Fabien egg O'Carroll 2021-12-13 15:42:32 +02:00 committed by Fabien 'egg' O'Carroll
parent 3b1696c57d
commit 12582c6cf4
5 changed files with 443 additions and 3 deletions

View File

@ -1941,6 +1941,12 @@ button.emoji-picker__category-button.active {
margin-top: 20px;
}
/* Before/After card
/* --------------------------------------------------------------- */
.kg-before-after-card img {
max-width: none;
}
/* Upload cards: audio and file
/* --------------------------------------------------------------- */

View File

@ -0,0 +1,191 @@
<KoenigCard
@env={{@env}}
@tagName="figure"
@class={{concat (kg-style "breakout" size=@payload.cardWidth) " flex flex-column"}}
@headerOffset={{@headerOffset}}
@toolbar={{this.toolbar}}
@payload={{@payload}}
@isSelected={{@isSelected}}
@isEditing={{@isEditing}}
@selectCard={{@selectCard}}
@deselectCard={{@deselectCard}}
@editCard={{@editCard}}
@hasEditMode={{true}}
@saveCard={{@saveCard}}
@saveAsSnippet={{@saveAsSnippet}}
@onLeaveEdit={{this.leaveEditMode}}
@addParagraphAfterCard={{@addParagraphAfterCard}}
@moveCursorToPrevSection={{@moveCursorToPrevSection}}
@moveCursorToNextSection={{@moveCursorToNextSection}}
@editor={{@editor}}
{{did-insert this.registerElement}}
as |card|
>
<GhUploader
@files={{this.files}}
@accept={{this.imageMimeTypes}}
@extensions={{this.imageExtensions}}
@onUploadStart={{this.uploadStart}}
@onUploadSuccess={{this.uploadSuccess}}
@onUploadFailure={{this.uploadFailed}}
@onFailed={{this.handleErrors}}
as |uploader|
>
<div class="kg-before-after-card relative margin-auto">
<div>
{{#if @payload.beforeImage}}
<img
src={{@payload.beforeImage.src}}
width={{this.imageWidth}}
/>
{{/if}}
</div>
<div
class="kg-before-after-card-overlay overflow-hidden absolute top-0 left-0"
style="{{this.overlayStyle}}"
>
{{#if @payload.afterImage}}
<img
src={{@payload.afterImage.src}}
width={{this.imageWidth}}
/>
{{/if}}
</div>
</div>
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.imageMimeTypes}} />
</div>
</GhUploader>
{{#if @isEditing}}
<KoenigSettingsPanel>
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
<div class="kg-settings-panel-control-label">After Image</div>
<div class="kg-settings-panel-control-input">
<div class="gh-btn-group icons">
{{#if @payload.afterImage}}
<button
type="button"
title="Remove"
class="gh-btn gh-btn-icon"
{{on "click" this.removeAfterImage}}
>
<span>Remove</span>
</button>
{{else}}
<button
type="button"
title="Upload"
class="gh-btn gh-btn-icon"
{{on "click" this.selectAfterImage}}
>
<span>Upload</span>
</button>
{{/if}}
</div>
</div>
</div>
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
<div class="kg-settings-panel-control-label">Before Image</div>
<div class="kg-settings-panel-control-input">
<div class="gh-btn-group icons">
{{#if @payload.beforeImage}}
<button
type="button"
title="Remove"
class="gh-btn gh-btn-icon"
{{on "click" this.removeBeforeImage}}
>
<span>Remove</span>
</button>
{{else}}
<button
type="button"
title="Upload"
class="gh-btn gh-btn-icon"
{{on "click" this.selectBeforeImage}}
>
<span>Upload</span>
</button>
{{/if}}
</div>
</div>
</div>
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
<div class="kg-settings-panel-control-label">Layout</div>
<div class="kg-settings-panel-control-input">
<div class="gh-btn-group icons">
<button
type="button"
title="Wide"
class="gh-btn gh-btn-icon {{
if (eq @payload.cardWidth "wide") "gh-btn-group-selected"
}}"
{{on "click" this.setLayoutWide}}
>
<span>Wide</span>
</button>
<button
type="button"
title="Full"
class="gh-btn gh-btn-icon {{
if (eq @payload.cardWidth "full") "gh-btn-group-selected"
}}"
{{on "click" this.setLayoutFull}}
>
<span>Full</span>
</button>
</div>
</div>
</div>
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
<div class="kg-settings-panel-control-label">Orientation</div>
<div class="kg-settings-panel-control-input">
<div class="gh-btn-group icons">
<button
type="button"
title="Horizontal"
class="gh-btn gh-btn-icon {{
if (eq @payload.orientation "horizontal") "gh-btn-group-selected"
}}"
{{on "click" this.setOrientationHorizontal}}
>
<span>Horizontal</span>
</button>
<button
type="button"
title="Vertical"
class="gh-btn gh-btn-icon {{
if (eq @payload.orientation "vertical") "gh-btn-group-selected"
}}"
{{on "click" this.setOrientationVertical}}
>
<span>Vertical</span>
</button>
</div>
</div>
</div>
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
<div class="kg-settings-panel-control-label">Starting position</div>
<div class="kg-settings-panel-control-input">
<input
type="range"
title="Starting position"
min="0"
max="100"
value="{{@payload.startingPosition}}"
{{on "input" this.setStartingPosition}}
/>
</div>
</div>
</KoenigSettingsPanel>
{{/if}}
{{#if (or @isSelected (clean-basic-html @payload.caption))}}
<card.CaptionInput
@caption={{@payload.caption}}
@update={{this.setCaption}}
@placeholder="Type caption for before/after (optional)"
/>
{{/if}}
</KoenigCard>

View File

@ -0,0 +1,230 @@
import $ from 'jquery';
import Component from '@glimmer/component';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {action} from '@ember/object';
import {utils as ghostHelperUtils} from '@tryghost/helpers';
import {run} from '@ember/runloop';
import {tracked} from '@glimmer/tracking';
const {countWords} = ghostHelperUtils;
export default class KoenigCardBeforeAfterComponent extends Component {
@tracked imageWidth;
files = null;
selectingFile = false;
imageMimeTypes = IMAGE_MIME_TYPES;
imageExtensions = IMAGE_EXTENSIONS;
get overlayStyle() {
if (this.args.payload.orientation === 'horizontal') {
return `width: ${this.args.payload.startingPosition}%`;
}
if (this.args.payload.orientation === 'vertical') {
return `height: ${this.args.payload.startingPosition}%`;
}
return null;
}
get wordCount() {
return countWords(this.payload.caption);
}
get toolbar() {
if (this.args.isEditing) {
return false;
}
return {
items: [{
buttonClass: 'fw4 flex items-center white',
icon: 'koenig/kg-edit',
iconClass: 'fill-white',
title: 'Edit',
text: '',
action: run.bind(this, this.args.editCard)
}]
};
}
updateImageDimensions() {
let beforeImage = this.args.payload.beforeImage;
let afterImage = this.args.payload.afterImage;
let smallestImageWidth = Math.min(
beforeImage ? beforeImage.width : Infinity,
afterImage ? afterImage.width : Infinity
);
try {
this.imageWidth = Math.min(
smallestImageWidth,
parseInt(getComputedStyle(this.element).getPropertyValue('width'))
);
} catch (err) {
this.imageWidth = Math.min(
smallestImageWidth,
0
);
}
}
constructor(owner, args) {
super(owner, args);
args.registerComponent(this);
if (!args.payload.orientation) {
args.payload.orientation = 'horizontal';
}
if (!args.payload.cardWidth) {
args.payload.cardWidth = 'wide';
}
if (!args.payload.startingPosition) {
args.payload.startingPosition = 50;
}
if (!args.payload.caption) {
args.payload.caption = null;
}
}
_triggerFileDialog(event) {
let target = event && event.target || this.element;
// simulate click to open file dialog
// using jQuery because IE11 doesn't support MouseEvent
$(target)
.closest('.__mobiledoc-card')
.find('input[type="file"]')
.trigger('click');
}
setupListeners(element) {
let observer = new MutationObserver(() => {
// @TODO Update on specific mutations
this.updateImageDimensions();
});
let config = {attributes: true, childList: true, subtree: true};
observer.observe(element, config);
this.updateImageDimensions();
}
// required for snippet rects to be calculated - editor reaches in to component,
// expecting a non-Glimmer component with a .element property
@action
registerElement(element) {
this.element = element;
this.setupListeners(element);
}
@action
uploadStart(file) {
// @TODO Handle in progress uploads
let existingImage = this.args.payload.afterImage || this.args.payload.beforeImage;
return new Promise((resolve, reject) => {
let objectURL = URL.createObjectURL(file);
let image = new Image();
image.addEventListener('load', () => {
let id = this.selectingFile;
this.selectingFile = false;
let metadata = {
aspectRatio: image.naturalWidth / image.naturalHeight,
id: id
};
if (existingImage) {
if (metadata.aspectRatio !== existingImage.aspectRatio) {
reject(new Error('Before/After images must have the same aspect ratio'));
}
}
resolve(metadata);
});
image.src = objectURL;
});
}
@action
uploadSuccess(file, metadata) {
let image = new Image();
image.addEventListener('load', () => {
let imageData = {
src: file.url,
aspectRatio: metadata.aspectRatio,
width: image.naturalWidth,
height: image.naturalHeight
};
let prop = `${metadata.id}Image`;
this.args.payload[prop] = imageData;
});
image.src = file.url;
}
@action
setLayoutWide() {
this.args.payload.cardWidth = 'wide';
}
@action
setLayoutFull() {
this.args.payload.cardWidth = 'full';
}
@action
setOrientationHorizontal() {
this.args.payload.orientation = 'horizontal';
}
@action
setOrientationVertical() {
this.args.payload.orientation = 'vertical';
}
@action
setStartingPosition(event) {
this.args.payload.startingPosition = Math.min(100, Math.max(0, parseInt(event.target.value)));
}
@action
removeBeforeImage() {
this.args.payload.beforeImage = null;
}
@action
selectBeforeImage() {
this.selectingFile = 'before';
this._triggerFileDialog();
}
@action
selectAfterImage() {
this.selectingFile = 'after';
this._triggerFileDialog();
}
@action
removeAfterImage() {
this.args.payload.afterImage = null;
}
@action
uploadFailed() {
}
@action
handleErrors() {
}
@action
setCaption(caption) {
this.args.payload.caption = caption;
}
@action
leaveEditMode() {
if (this.isEmpty) {
// afterRender is required to avoid double modification of `isSelected`
// TODO: see if there's a way to avoid afterRender
run.scheduleOnce('afterRender', this, this.args.deleteCard);
}
}
}

View File

@ -21,7 +21,8 @@ export const CARD_COMPONENT_MAP = {
video: 'koenig-card-video',
audio: 'koenig-card-audio',
file: 'koenig-card-file',
product: 'koenig-card-product'
product: 'koenig-card-product',
'before-after': 'koenig-card-before-after'
};
// map card names to generic icons (used for ghost elements when dragging)
@ -45,7 +46,8 @@ export const CARD_ICON_MAP = {
video: 'koenig/kg-card-type-video',
audio: 'koenig/kg-card-type-audio',
file: 'koenig/kg-card-type-file',
product: 'koenig/kg-card-type-product'
product: 'koenig/kg-card-type-product',
'before-after': 'koenig-card-before-after'
};
// TODO: move koenigOptions directly into cards now that card components register
@ -70,7 +72,8 @@ export default [
createComponentCard('audio'),
createComponentCard('file'),
createComponentCard('product'),
createComponentCard('paywall', {hasEditMode: false, selectAfterInsert: false})
createComponentCard('paywall', {hasEditMode: false, selectAfterInsert: false}),
createComponentCard('before-after')
];
export const CARD_MENU = [
@ -245,6 +248,15 @@ export const CARD_MENU = [
type: 'card',
replaceArg: 'product',
isAvailable: 'feature.productCard'
},
{
label: 'Before/After',
icon: 'koenig/kg-card-type-other',
desc: 'Compare two images',
matches: ['before', 'after', 'compare'],
type: 'card',
replaceArg: 'before-after',
isAvailable: 'feature.beforeAfterCard'
}]
},
{

View File

@ -0,0 +1 @@
export {default} from 'koenig-editor/components/koenig-card-before-after';