Koenig reboot - rich text (#952)
refs https://github.com/TryGhost/Ghost/issues/9311 Koenig is being fully rebooted, first port of call is to focus on getting the rich-text only aspect of mobiledoc-kit working with our popup toolbar. - renames old koenig implementation (used for reference, will eventually be deleted) - new `{{koenig-editor}}` mobiledoc-kit component implementation based on ember-mobiledoc-editor - markdown text expansions - new `{{gh-koenig-edtor}}` that wraps our title+editor and handles keyboard navigation between the two - clicks below content will focus the editor - new `{{koenig-toolbar}}` component for the popup formatting toolbar with improved behaviour and simplified code
120
ghost/admin/app/components/gh-koenig-editor.js
Normal file
|
@ -0,0 +1,120 @@
|
|||
import Component from '@ember/component';
|
||||
|
||||
export default Component.extend({
|
||||
|
||||
// public attrs
|
||||
tagName: '',
|
||||
title: '',
|
||||
titlePlaceholder: '',
|
||||
body: null,
|
||||
bodyPlaceholder: '',
|
||||
bodyAutofocus: false,
|
||||
|
||||
// internal properties
|
||||
_title: null,
|
||||
_editor: null,
|
||||
|
||||
// closure actions
|
||||
onTitleChange() {},
|
||||
onTitleBlur() {},
|
||||
onBodyChange() {},
|
||||
|
||||
actions: {
|
||||
// triggered when a click is registered on .gh-koenig-editor-pane
|
||||
focusEditor(event) {
|
||||
// if a click occurs on the editor canvas, focus the editor and put
|
||||
// the cursor at the end of the document. Allows for a much larger
|
||||
// hit area for focusing the editor when it has no or little content
|
||||
if (event.target.tagName === 'ARTICLE' && event.target.classList.contains('koenig-editor')) {
|
||||
let {post} = this._editor;
|
||||
let range = post.toRange();
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this._editor.focus();
|
||||
this._editor.run((postEditor) => {
|
||||
postEditor.setRange(range.tail.section.tailPosition());
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/* title related actions -------------------------------------------- */
|
||||
|
||||
onTitleCreated(titleElement) {
|
||||
this._title = titleElement;
|
||||
},
|
||||
|
||||
onTitleChange(newTitle) {
|
||||
this.onTitleChange(newTitle);
|
||||
},
|
||||
|
||||
onTitleFocusOut() {
|
||||
this.onTitleBlur();
|
||||
},
|
||||
|
||||
onTitleKeydown(event) {
|
||||
let value = event.target.value;
|
||||
let selectionStart = event.target.selectionStart;
|
||||
|
||||
// enter will always focus the editor
|
||||
// down arrow will only focus the editor when the cursor is at the
|
||||
// end of the input to preserve the default OS behaviour
|
||||
if (
|
||||
event.key === 'Enter' ||
|
||||
event.key === 'Tab' ||
|
||||
(event.key === 'ArrowDown' && (!value || selectionStart === value.length))
|
||||
) {
|
||||
event.preventDefault();
|
||||
this._editor.focus();
|
||||
}
|
||||
},
|
||||
|
||||
/* body related actions --------------------------------------------- */
|
||||
|
||||
onEditorCreated(editor) {
|
||||
this._setupEditor(editor);
|
||||
},
|
||||
|
||||
onBodyChange(newMobiledoc) {
|
||||
this.onBodyChange(newMobiledoc);
|
||||
}
|
||||
},
|
||||
|
||||
/* public methods ------------------------------------------------------- */
|
||||
|
||||
/* internal methods ----------------------------------------------------- */
|
||||
|
||||
_setupEditor(editor) {
|
||||
let component = this;
|
||||
|
||||
this._editor = editor;
|
||||
|
||||
// focus the title when pressing UP if cursor is at the beginning of doc
|
||||
editor.registerKeyCommand({
|
||||
str: 'UP',
|
||||
run(editor) {
|
||||
let cursorHead = editor.cursor.offsets.head;
|
||||
|
||||
if (
|
||||
editor.hasCursor()
|
||||
&& cursorHead.offset === 0
|
||||
&& (!cursorHead.section || !cursorHead.section.prev)
|
||||
) {
|
||||
component._title.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// focus the title when pressing SHIFT+TAB
|
||||
editor.registerKeyCommand({
|
||||
str: 'SHIFT+TAB',
|
||||
run() {
|
||||
component._title.focus();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
|
@ -27,6 +27,10 @@ export default OneWayTextarea.extend(TextInputMixin, {
|
|||
if (this.get('autoExpand')) {
|
||||
run.scheduleOnce('afterRender', this, this._setupAutoExpand);
|
||||
}
|
||||
|
||||
if (this.get('didCreateTextarea')) {
|
||||
this.get('didCreateTextarea')(this.element);
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
|
|
|
@ -5,7 +5,7 @@ import attr from 'ember-data/attr';
|
|||
import boundOneWay from 'ghost-admin/utils/bound-one-way';
|
||||
import moment from 'moment';
|
||||
import {BLANK_DOC as BLANK_MARKDOWN} from 'ghost-admin/components/gh-markdown-editor';
|
||||
import {BLANK_DOC as BLANK_MOBILEDOC} from 'gh-koenig/components/gh-koenig';
|
||||
import {BLANK_DOC as BLANK_MOBILEDOC} from 'koenig-editor/components/koenig-editor';
|
||||
import {belongsTo, hasMany} from 'ember-data/relationships';
|
||||
import {compare} from '@ember/utils';
|
||||
import {computed} from '@ember/object';
|
||||
|
@ -344,8 +344,8 @@ export default Model.extend(Comparable, ValidationEngine, {
|
|||
isCompatibleWithMarkdownEditor() {
|
||||
let mobiledoc = this.get('mobiledoc');
|
||||
|
||||
if (
|
||||
mobiledoc.markups.length === 0
|
||||
if (mobiledoc
|
||||
&& mobiledoc.markups.length === 0
|
||||
&& mobiledoc.cards.length === 1
|
||||
&& mobiledoc.cards[0][0] === 'card-markdown'
|
||||
&& mobiledoc.sections.length === 1
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
@import "components/popovers.css";
|
||||
@import "components/tour.css";
|
||||
@import "components/unsplash.css";
|
||||
@import "components/koenig";
|
||||
|
||||
|
||||
/* Layouts: Groups of Components
|
||||
|
@ -54,11 +55,6 @@
|
|||
@import "layouts/subscribers.css";
|
||||
|
||||
|
||||
/* Addons: gh-koenig
|
||||
/* ---------------------------------------------------------- */
|
||||
@import "addons/gh-koenig/gh-koenig.css";
|
||||
|
||||
|
||||
:root {
|
||||
--darkgrey: #e5eff5;
|
||||
--midgrey: #738a94;
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
@import "components/popovers.css";
|
||||
@import "components/tour.css";
|
||||
@import "components/unsplash.css";
|
||||
@import "components/koenig";
|
||||
|
||||
|
||||
/* Layouts: Groups of Components
|
||||
|
@ -54,11 +55,6 @@
|
|||
@import "layouts/subscribers.css";
|
||||
|
||||
|
||||
/* Addons: gh-koenig
|
||||
/* ---------------------------------------------------------- */
|
||||
@import "addons/gh-koenig/gh-koenig.css";
|
||||
|
||||
|
||||
/* ---------------------------✈️----------------------------- */
|
||||
/* I COMMITTED THIS COMMENT FROM A PLANE: http://bit.ly/2on5pmq
|
||||
/* --------------------------------------------------------- */
|
||||
|
|
345
ghost/admin/app/styles/components/koenig.css
Normal file
|
@ -0,0 +1,345 @@
|
|||
/* TODO: move these back into the editor.css layout? */
|
||||
|
||||
/* scrollable container */
|
||||
.gh-koenig-editor {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* padded container housing title + editor canvas, scrollable content */
|
||||
.gh-koenig-editor-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: 10vw 4vw;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.gh-koenig-editor-pane {
|
||||
padding: 15vw 4vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* use flex-grow to fill the available vertical space so clicks outside the
|
||||
editor content can trigger focus */
|
||||
.gh-koenig-editor-pane .koenig-editor {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* NOTE: everything from this point should be Koenig addon specific */
|
||||
|
||||
/* Editor canvas layout ----------------------------------------------------- */
|
||||
|
||||
.koenig-editor {
|
||||
position: relative; /* necessary to position toolbar etc */
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Formatting Toolbar ------------------------------------------------------- */
|
||||
|
||||
.koenig-toolbar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
color: color(var(--lightgrey) l(-10%));
|
||||
background: linear-gradient(
|
||||
color(var(--darkgrey) l(-3%)),
|
||||
color(var(--darkgrey) l(-8%))
|
||||
);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 1px color(var(--darkgrey) l(-10%)), 0 8px 16px rgba(26,39,49,0.16), rgba(255,255,255,0.09) 0 1px 0 0 inset;
|
||||
z-index:110; /* places it above the title */
|
||||
pointer-events: none !important; /* no interactivity when hidden */
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.koenig-toolbar--visible {
|
||||
pointer-events: auto !important; /* make sure the buttons work */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.koenig-toolbar:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: transparent 10px solid;
|
||||
border-right: transparent 10px solid;
|
||||
border-top: color(var(--darkgrey) l(-10%)) 8px solid;
|
||||
transition-property: left;
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
.koenig-toolbar.tick-above:after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.koenig-toolbar.is-link {
|
||||
width: 263px;
|
||||
height: 40px;
|
||||
}
|
||||
.koenig-toolbar.is-link input {
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
padding:5px;
|
||||
}
|
||||
.koenig-toolbar.is-touch {
|
||||
position: fixed !important;
|
||||
top: 70px;
|
||||
right: 40px;
|
||||
}
|
||||
|
||||
.koenig-toolbar.is-touch:after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
font-size: 1.6rem;
|
||||
line-height: 40px;
|
||||
transition: text-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn:first-child {
|
||||
width: 43px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.koenig-toolbar-btn:last-child {
|
||||
width: 43px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn svg {
|
||||
height: 1.4rem;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn svg g {
|
||||
stroke-width: 2px;
|
||||
stroke: color(var(--lightgrey) l(-10%));
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn:hover,
|
||||
.koenig-toolbar-btn.selected {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-shadow: #000 0 1px 6px;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn:hover svg g {
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-italic {
|
||||
width: 31px;
|
||||
font-size: 1.7rem;
|
||||
text-indent: -1px;
|
||||
font-style: italic;
|
||||
font-family: Georgia, Times, serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-strike {
|
||||
text-decoration: line-through;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-h1 {
|
||||
font-variant: small-caps;
|
||||
font-weight: 700;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-h2 {
|
||||
font-weight: 700;
|
||||
font-size: 0.9em;
|
||||
font-variant: small-caps;
|
||||
line-height: 42px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.koenig-toolbar-btn-quote {
|
||||
font-size: 4rem;
|
||||
line-height: 62px;
|
||||
font-family: Georgia, Times, serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.koenig-toolbar-divider {
|
||||
height: 40px;
|
||||
width: 1px;
|
||||
margin: 0 9px 0 8px;
|
||||
background: color(var(--darkgrey) l(-10%));
|
||||
box-shadow: rgba(255,255,255,0.04) 1px 0 0 0;
|
||||
}
|
||||
|
||||
/* ⨁ menu ------------------------------------------------------------------ */
|
||||
|
||||
/* Slash shortcut menu ------------------------------------------------------ */
|
||||
|
||||
/* Menu items --------------------------------------------------------------- */
|
||||
|
||||
/* mobiledoc-kit base styles ------------------------------------------------
|
||||
* NOTE: adapted from https://github.com/bustle/mobiledoc-kit/blob/master/src/css/mobiledoc-kit.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Editor
|
||||
*/
|
||||
|
||||
/* TODO: update to match Ghost styles */
|
||||
.__mobiledoc-editor {
|
||||
position: relative;
|
||||
font-family: var(--font-family);
|
||||
font-size: 1.7rem;
|
||||
resize: none;
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.1px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor.__has-no-content:after {
|
||||
content: attr(data-placeholder);
|
||||
color: #bbb;
|
||||
cursor: text;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor a {
|
||||
color: #0b8bff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* override of global.css block display */
|
||||
.__mobiledoc-editor i {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor h1,
|
||||
.__mobiledoc-editor h2,
|
||||
.__mobiledoc-editor h3,
|
||||
.__mobiledoc-editor h4,
|
||||
.__mobiledoc-editor h5,
|
||||
.__mobiledoc-editor h6 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor blockquote {
|
||||
border-left: 4px solid #0b8bff;
|
||||
margin: 1em 0 1em -1.2em;
|
||||
padding-left: 1.05em;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.__mobiledoc-editor div,
|
||||
.__mobiledoc-editor iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards
|
||||
*/
|
||||
|
||||
.__mobiledoc-card {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltips
|
||||
*/
|
||||
|
||||
@-webkit-keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.__mobiledoc-tooltip {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.7em;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
background-color: rgba(43,43,43,0.9);
|
||||
border-radius: 3px;
|
||||
line-height: 1em;
|
||||
padding: 0.7em 0.9em;
|
||||
color: #FFF;
|
||||
-webkit-animation: fade-in 0.2s;
|
||||
animation: fade-in 0.2s;
|
||||
}
|
||||
|
||||
.__mobiledoc-tooltip:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 0.4em solid transparent;
|
||||
border-right: 0.4em solid transparent;
|
||||
border-bottom: 0.4em solid rgba(43,43,43,0.9);
|
||||
top: -0.4em;
|
||||
margin-left: -0.4em;
|
||||
}
|
||||
|
||||
/* help keeps mouseover state when moving from link to tooltip */
|
||||
.__mobiledoc-tooltip:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -0.4em;
|
||||
height: 0.4em;
|
||||
}
|
||||
|
||||
.__mobiledoc-tooltip a {
|
||||
color: #FFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.__mobiledoc-tooltip a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
25
ghost/admin/app/templates/components/gh-koenig-editor.hbs
Normal file
|
@ -0,0 +1,25 @@
|
|||
{{!-- scrollable container --}}
|
||||
<div class="gh-koenig-editor">
|
||||
{{!-- full height content pane --}}
|
||||
<div class="gh-koenig-editor-pane" onclick={{action "focusEditor"}}>
|
||||
{{gh-textarea title
|
||||
class="gh-editor-title"
|
||||
placeholder=titlePlaceholder
|
||||
tabindex="1"
|
||||
autoExpand=".gh-koenig-editor"
|
||||
update=(action "onTitleChange")
|
||||
focusOut=(action "onTitleFocusOut")
|
||||
keyDown=(action "onTitleKeydown")
|
||||
didCreateTextarea=(action "onTitleCreated")
|
||||
}}
|
||||
|
||||
{{koenig-editor
|
||||
mobiledoc=body
|
||||
placeholder=bodyPlaceholder
|
||||
autofocus=bodyAutofocus
|
||||
spellcheck=true
|
||||
onChange=(action "onBodyChange")
|
||||
didCreateEditor=(action "onEditorCreated")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
|
@ -33,44 +33,20 @@
|
|||
</header>
|
||||
|
||||
{{#if useKoenig}}
|
||||
<div class="gh-editor-container needsclick">
|
||||
<div class="gh-editor-inner">
|
||||
{{!--
|
||||
NOTE: the mobiledoc property is unbound so that the setting the
|
||||
serialized version onChange doesn't cause a deserialization and
|
||||
re-render of the editor on every key press / editor change
|
||||
|
||||
TODO: note above is no longer correct, changed to readonly to
|
||||
fix a persistent editor content bug that occurred due to the
|
||||
editor not being re-rendered on edit->new transition.
|
||||
|
||||
Needs perf investigation!
|
||||
--}}
|
||||
{{#gh-koenig
|
||||
mobiledoc=(readonly model.scratch)
|
||||
onChange=(action "updateScratch")
|
||||
autofocus=shouldFocusEditor
|
||||
tabindex="2"
|
||||
titleSelector="#kg-title-input"
|
||||
containerSelector=".gh-editor-container"
|
||||
wordcountDidChange=(action "setWordcount")
|
||||
as |koenig|
|
||||
}}
|
||||
{{koenig-title-input
|
||||
id="koenig-title-input"
|
||||
val=(readonly model.titleScratch)
|
||||
onChange=(action "updateTitleScratch")
|
||||
tabindex="1"
|
||||
autofocus=shouldFocusTitle
|
||||
focusOut=(action (perform saveTitle))
|
||||
editor=(readonly koenig.editor)
|
||||
editorHasRendered=koenig.hasRendered
|
||||
editorMenuIsOpen=koenig.isMenuOpen
|
||||
}}
|
||||
{{/gh-koenig}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-editor-wordcount">{{pluralize wordcount 'word'}}.</div>
|
||||
{{!--
|
||||
gh-koenig-editor acts as a wrapper around the title input and
|
||||
koenig editor canvas to support Ghost-specific editor behaviour
|
||||
--}}
|
||||
{{gh-koenig-editor
|
||||
title=(readonly post.titleScratch)
|
||||
titlePlaceholder="Story Title"
|
||||
onTitleChange=(action "updateTitleScratch")
|
||||
onTitleBlur=(action (perform saveTitle))
|
||||
body=(readonly post.scratch)
|
||||
bodyPlaceholder="Begin writing your story..."
|
||||
bodyAutofocus=shouldFocusEditor
|
||||
onBodyChange=(action "updateScratch")
|
||||
}}
|
||||
|
||||
{{else}}
|
||||
|
||||
|
|
Before Width: | Height: | Size: 447 B After Width: | Height: | Size: 447 B |
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 466 B |
Before Width: | Height: | Size: 740 B After Width: | Height: | Size: 740 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 745 B After Width: | Height: | Size: 745 B |
Before Width: | Height: | Size: 810 B After Width: | Height: | Size: 810 B |
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 305 B |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 702 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 364 B After Width: | Height: | Size: 364 B |
Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 689 B |
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 696 B |
Before Width: | Height: | Size: 675 B After Width: | Height: | Size: 675 B |
Before Width: | Height: | Size: 819 B After Width: | Height: | Size: 819 B |
Before Width: | Height: | Size: 820 B After Width: | Height: | Size: 820 B |
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 442 B |
270
ghost/admin/lib/koenig-editor/addon/components/koenig-editor.js
Normal file
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* Based on ember-mobiledoc-editor
|
||||
* https://github.com/bustle/ember-mobiledoc-editor
|
||||
*/
|
||||
|
||||
import Component from '@ember/component';
|
||||
import Editor from 'mobiledoc-kit/editor/editor';
|
||||
import Ember from 'ember';
|
||||
import layout from '../templates/components/koenig-editor';
|
||||
import registerTextExpansions from '../options/text-expansions';
|
||||
import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
|
||||
import {assign} from '@ember/polyfills';
|
||||
import {camelize, capitalize} from '@ember/string';
|
||||
import {computed} from '@ember/object';
|
||||
import {run} from '@ember/runloop';
|
||||
|
||||
// used in test helpers to grab a reference to the underlying mobiledoc editor
|
||||
export const TESTING_EXPANDO_PROPERTY = '__mobiledoc_kit_editor';
|
||||
|
||||
// blank doc contains a single empty paragraph so that there's some content for
|
||||
// the cursor to start in
|
||||
export const BLANK_DOC = {
|
||||
version: MOBILEDOC_VERSION,
|
||||
markups: [],
|
||||
atoms: [],
|
||||
cards: [],
|
||||
sections: [
|
||||
[1, 'p', [
|
||||
[0, [], 0, '']
|
||||
]]
|
||||
]
|
||||
};
|
||||
|
||||
function arrayToMap(array) {
|
||||
let map = Object.create(null);
|
||||
array.forEach((key) => {
|
||||
if (key) { // skip undefined/falsy key values
|
||||
key = `is${capitalize(camelize(key))}`;
|
||||
map[key] = true;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export default Component.extend({
|
||||
layout,
|
||||
|
||||
tagName: 'article',
|
||||
classNames: ['koenig-editor'],
|
||||
|
||||
// public attrs
|
||||
mobiledoc: null,
|
||||
placeholder: 'Write here...',
|
||||
autofocus: false,
|
||||
spellcheck: true,
|
||||
options: null,
|
||||
scrollContainer: '',
|
||||
|
||||
// internal properties
|
||||
editor: null,
|
||||
activeMarkupTagNames: null,
|
||||
activeSectionTagNames: null,
|
||||
selectedRange: null,
|
||||
|
||||
// private properties
|
||||
_localMobiledoc: null,
|
||||
_upstreamMobiledoc: null,
|
||||
_startedRunLoop: false,
|
||||
_lastIsEditingDisabled: false,
|
||||
_isRenderingEditor: false,
|
||||
|
||||
// closure actions
|
||||
willCreateEditor() {},
|
||||
didCreateEditor() {},
|
||||
onChange() {},
|
||||
|
||||
/* computed properties -------------------------------------------------- */
|
||||
|
||||
// merge in named options with the `options` property data-bag
|
||||
editorOptions: computed(function () {
|
||||
let options = this.get('options') || {};
|
||||
|
||||
return assign({
|
||||
placeholder: this.get('placeholder'),
|
||||
spellcheck: this.get('spellcheck'),
|
||||
autofocus: this.get('autofocus')
|
||||
}, options);
|
||||
}),
|
||||
|
||||
/* lifecycle hooks ------------------------------------------------------ */
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
// set a blank mobiledoc if we didn't receive anything
|
||||
let mobiledoc = this.get('mobiledoc');
|
||||
if (!mobiledoc) {
|
||||
mobiledoc = BLANK_DOC;
|
||||
this.set('mobiledoc', mobiledoc);
|
||||
}
|
||||
|
||||
this._startedRunLoop = false;
|
||||
},
|
||||
|
||||
willRender() {
|
||||
// use a default mobiledoc. If there are no changes then return early
|
||||
let mobiledoc = this.get('mobiledoc') || BLANK_DOC;
|
||||
let mobiledocIsSame =
|
||||
(this._localMobiledoc && this._localMobiledoc === mobiledoc) ||
|
||||
(this._upstreamMobiledoc && this._upstreamMobiledoc === mobiledoc);
|
||||
let isEditingDisabledIsSame =
|
||||
this._lastIsEditingDisabled === this.get('isEditingDisabled');
|
||||
|
||||
// no change to mobiledoc, no need to recreate the editor
|
||||
if (mobiledocIsSame && isEditingDisabledIsSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update our internal references
|
||||
this._lastIsEditingDisabled = this.get('isEditingDisabled');
|
||||
this._upstreamMobiledoc = mobiledoc;
|
||||
this._localMobiledoc = null;
|
||||
|
||||
// trigger the willCreateEditor closure action
|
||||
this.willCreateEditor();
|
||||
|
||||
// teardown any old editor that might be around
|
||||
let editor = this.get('editor');
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
}
|
||||
|
||||
// create a new editor
|
||||
let editorOptions = this.get('editorOptions');
|
||||
editorOptions.mobiledoc = mobiledoc;
|
||||
editor = new Editor(editorOptions);
|
||||
|
||||
// set up key commands and text expansions (MD conversion)
|
||||
registerTextExpansions(editor);
|
||||
|
||||
// set up editor hooks
|
||||
editor.willRender(() => {
|
||||
// The editor's render/rerender will happen after this `editor.willRender`,
|
||||
// so we explicitly start a runloop here if there is none, so that the
|
||||
// add/remove card hooks happen inside a runloop.
|
||||
// When pasting text that gets turned into a card, for example,
|
||||
// the add card hook would run outside the runloop if we didn't begin a new
|
||||
// one now.
|
||||
if (!run.currentRunLoop) {
|
||||
this._startedRunLoop = true;
|
||||
run.begin();
|
||||
}
|
||||
});
|
||||
editor.didRender(() => {
|
||||
// if we had explicitly started a runloop in `editor.willRender`,
|
||||
// we must explicitly end it here
|
||||
if (this._startedRunLoop) {
|
||||
this._startedRunLoop = false;
|
||||
run.end();
|
||||
}
|
||||
});
|
||||
editor.postDidChange(() => {
|
||||
run.join(() => {
|
||||
this.postDidChange(editor);
|
||||
});
|
||||
});
|
||||
editor.cursorDidChange(() => {
|
||||
run.join(() => {
|
||||
this.cursorDidChange(editor);
|
||||
});
|
||||
});
|
||||
editor.inputModeDidChange(() => {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
run.join(() => {
|
||||
this.inputModeDidChange(editor);
|
||||
});
|
||||
});
|
||||
|
||||
if (this.get('isEditingDisabled')) {
|
||||
editor.disableEditing();
|
||||
}
|
||||
|
||||
this.set('editor', editor);
|
||||
this.didCreateEditor(editor);
|
||||
},
|
||||
|
||||
// our ember component has rendered, now we need to render the mobiledoc
|
||||
// editor itself if necessary
|
||||
didRender() {
|
||||
this._super(...arguments);
|
||||
let editor = this.get('editor');
|
||||
if (!editor.hasRendered) {
|
||||
let editorElement = this.element.querySelector('.koenig-editor__editor');
|
||||
this._isRenderingEditor = true;
|
||||
editor.render(editorElement);
|
||||
this._isRenderingEditor = false;
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
let editor = this.get('editor');
|
||||
editor.destroy();
|
||||
this._super(...arguments);
|
||||
},
|
||||
|
||||
actions: {
|
||||
toggleMarkup(markupTagName) {
|
||||
let editor = this.get('editor');
|
||||
editor.toggleMarkup(markupTagName);
|
||||
},
|
||||
|
||||
toggleSection(sectionTagName) {
|
||||
let editor = this.get('editor');
|
||||
editor.toggleSection(sectionTagName);
|
||||
}
|
||||
},
|
||||
|
||||
/* public methods ------------------------------------------------------- */
|
||||
|
||||
postDidChange(editor) {
|
||||
let serializeVersion = this.get('serializeVersion');
|
||||
let updatedMobiledoc = editor.serialize(serializeVersion);
|
||||
this._localMobiledoc = updatedMobiledoc;
|
||||
|
||||
// trigger closure action
|
||||
this.onChange(updatedMobiledoc);
|
||||
},
|
||||
|
||||
cursorDidChange(editor) {
|
||||
this.set('selectedRange', editor.range);
|
||||
},
|
||||
|
||||
// fired when the active section(s) or markup(s) at the current cursor
|
||||
// position or selection have changed. We use this event to update the
|
||||
// activeMarkup/section tag lists which control button states in our popup
|
||||
// toolbar
|
||||
inputModeDidChange(editor) {
|
||||
let markupTags = arrayToMap(editor.activeMarkups.map(m => m.tagName));
|
||||
// editor.activeSections are leaf sections.
|
||||
// Map parent section tag names (e.g. 'p', 'ul', 'ol') so that list buttons
|
||||
// are updated.
|
||||
// eslint-disable-next-line no-confusing-arrow
|
||||
let sectionParentTagNames = editor.activeSections.map(s => s.isNested ? s.parent.tagName : s.tagName);
|
||||
let sectionTags = arrayToMap(sectionParentTagNames);
|
||||
|
||||
// Avoid updating this component's properties synchronously while
|
||||
// rendering the editor (after rendering the component) because it
|
||||
// causes Ember to display deprecation warnings
|
||||
if (this._isRenderingEditor) {
|
||||
run.schedule('afterRender', () => {
|
||||
this.set('activeMarkupTagNames', markupTags);
|
||||
this.set('activeSectionTagNames', sectionTags);
|
||||
});
|
||||
} else {
|
||||
this.set('activeMarkupTagNames', markupTags);
|
||||
this.set('activeSectionTagNames', sectionTags);
|
||||
}
|
||||
},
|
||||
|
||||
/* internal methods ----------------------------------------------------- */
|
||||
|
||||
// store a reference to the editor for the acceptance test helpers
|
||||
_setExpandoProperty(editor) {
|
||||
if (this.element && Ember.testing) {
|
||||
this.element[TESTING_EXPANDO_PROPERTY] = editor;
|
||||
}
|
||||
}
|
||||
});
|