mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
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:
parent
3b1696c57d
commit
12582c6cf4
@ -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
|
||||
/* --------------------------------------------------------------- */
|
||||
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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'
|
||||
}]
|
||||
},
|
||||
{
|
||||
|
@ -0,0 +1 @@
|
||||
export {default} from 'koenig-editor/components/koenig-card-before-after';
|
Loading…
Reference in New Issue
Block a user