Skip to content

Commit

Permalink
Handle cut/copy/paste events
Browse files Browse the repository at this point in the history
  * Rename PostParser -> DOMParser, remove old DOMParser
  * Add many fixtures with example HTML from copying in a google doc document
  * Add postEditor#insertPost. It returns a Position used to position the cursor after inserting content
  * Only allow certain attributes from parsed content
  * filter markup attributes at instantiation
  * when copying, override the native HTML and text copied to display a link to copy-paste issue #180
  * copy/paste cards and lists
  * Handle cut event

fixes #111
  • Loading branch information
bantic committed Oct 22, 2015
1 parent 682dbac commit c2bbafe
Show file tree
Hide file tree
Showing 28 changed files with 1,412 additions and 786 deletions.
44 changes: 33 additions & 11 deletions src/js/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import Key from '../utils/key';
import EventEmitter from '../utils/event-emitter';

import MobiledocParser from '../parsers/mobiledoc';
import PostParser from '../parsers/post';
import HTMLParser from '../parsers/html';
import DOMParser from '../parsers/dom';
import Renderer from 'content-kit-editor/renderers/editor-dom';
import RenderTree from 'content-kit-editor/models/render-tree';
import MobiledocRenderer from '../renderers/mobiledoc';

import { mergeWithOptions } from 'content-kit-utils';
import { clearChildNodes, addClassName, parseHTML } from '../utils/dom-utils';
import { clearChildNodes, addClassName } from '../utils/dom-utils';
import { forEach, filter } from '../utils/array-utils';
import { setData } from '../utils/element-utils';
import mixin from '../utils/mixin';
Expand All @@ -30,11 +30,14 @@ import {
import { capitalize } from '../utils/string-utils';
import LifecycleCallbacksMixin from '../utils/lifecycle-callbacks';
import { CARD_MODES } from '../models/card';
import { detect } from '../utils/array-utils';
import {
parsePostFromPaste,
setClipboardCopyData
} from '../utils/paste-utils';

export const EDITOR_ELEMENT_CLASS_NAME = 'ck-editor';

import { detect } from '../utils/array-utils';

const defaults = {
placeholder: 'Write here...',
spellcheck: true,
Expand Down Expand Up @@ -78,7 +81,7 @@ class Editor {
DEFAULT_TEXT_EXPANSIONS.forEach(e => this.registerExpansion(e));
DEFAULT_KEY_COMMANDS.forEach(kc => this.registerKeyCommand(kc));

this._parser = new PostParser(this.builder);
this._parser = new DOMParser(this.builder);
this._renderer = new Renderer(this, this.cards, this.unknownCardHandler, this.cardOptions);

this.post = this.loadPost();
Expand All @@ -99,9 +102,10 @@ class Editor {
return new MobiledocParser(this.builder).parse(this.mobiledoc);
} else if (this.html) {
if (typeof this.html === 'string') {
this.html = parseHTML(this.html);
return new HTMLParser(this.builder).parse(this.html);
} else { // DOM
return this._parser.parse(this.html);
}
return new DOMParser(this.builder).parse(this.html);
} else {
return this.builder.createPost();
}
Expand Down Expand Up @@ -208,8 +212,6 @@ class Editor {
}

handleDeletion(event) {
event.preventDefault();

const range = this.cursor.offsets;

if (this.cursor.hasSelection()) {
Expand Down Expand Up @@ -505,7 +507,7 @@ class Editor {
}

_setupListeners() {
const elementEvents = ['keydown', 'keyup', 'input', 'paste'];
const elementEvents = ['keydown', 'keyup', 'input', 'cut', 'copy', 'paste'];
const documentEvents = ['mouseup'];

elementEvents.forEach(eventName => {
Expand Down Expand Up @@ -630,8 +632,28 @@ class Editor {
return false;
}

handleCut(event) {
this.handleCopy(event);
this.handleDeletion(event);
}

handleCopy(event) {
event.preventDefault();
setClipboardCopyData(event, this);
}

handlePaste(event) {
event.preventDefault(); // FIXME for now, just prevent pasting
event.preventDefault();

let pastedPost = parsePostFromPaste(event, this.builder);

const range = this.cursor.offsets;
let nextPosition;
this.run(postEditor => {
nextPosition = postEditor.insertPost(range.head, pastedPost);
});

this.cursor.moveToPosition(nextPosition);
}

// @private
Expand Down
74 changes: 69 additions & 5 deletions src/js/editor/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import {
import { isMarkerable } from '../models/_section';
import { POST_TYPE, MARKUP_SECTION_TYPE, LIST_ITEM_TYPE } from '../models/types';
import Position from '../utils/cursor/position';
import {
isArrayEqual, forEach, filter, compact
} from '../utils/array-utils';
import { isArrayEqual, forEach, filter, compact } from '../utils/array-utils';
import { DIRECTION } from '../utils/key';
import LifecycleCallbacksMixin from '../utils/lifecycle-callbacks';
import mixin from '../utils/mixin';
Expand Down Expand Up @@ -621,10 +619,33 @@ class PostEditor {
});
}

/**
* @method insertMarkers
* @param {Position} position to insert at
* @param {Array} markers to insert
* @return {Position} position at end of inserted markers
* @private
*/
insertMarkers(position, markers=[]) {
let { section, offset } = position;
this.splitSectionMarkerAtOffset(section, offset);
let {marker:prevMarker} = section.markerPositionAtOffset(offset);
let currentMarker = offset === 0 ? prevMarker : prevMarker.next;

markers.forEach(marker => {
marker = marker.clone();
section.markers.insertBefore(marker, currentMarker);
offset += marker.length;
this._markDirty(marker);
});

return new Position(section, offset);
}

/**
* Toggle the given markup on the current selection. If anything in the current
* selection has the markup, it will be removed. If nothing in the selection
* has the markup, it will be added to everything in the selection.
* selection has the markup, the markup will be removed from it. If nothing in the selection
* has the markup, the markup will be added to everything in the selection.
*
* Usage:
*
Expand Down Expand Up @@ -728,6 +749,49 @@ class PostEditor {
this.insertSectionBefore(this.editor.post.sections, section, null);
}

/**
* @method insertPost
* @param {Position} position
* @param {Post} post
* @return {Position} position at end of inserted content
* @private
*/
insertPost(position, newPost) {
const post = this.editor.post;
const shouldSplitSection = newPost.sections.length > 1;

if (!shouldSplitSection) {
const markers = newPost.sections.head.markers;
return this.insertMarkers(position, markers);
}

let [preSplit, postSplit] = this.splitSection(position);
const headSection = newPost.sections.head;
let lastInsertedSection = headSection;

newPost.sections.forEach(section => {
if (section === headSection) {
this._mergeSectionAtEnd(section, preSplit);
} else {
section = section.clone();
lastInsertedSection = section;
this.insertSectionBefore(post.sections, section, postSplit);
}
});

if (postSplit.isBlank) {
this.removeSection(postSplit);
}

return new Position(lastInsertedSection, lastInsertedSection.length);
}

_mergeSectionAtEnd(sectionToMerge, existingSection) {
const markers = sectionToMerge.markers;
const position = new Position(existingSection, existingSection.length);
return this.insertMarkers(position, markers);
}

/**
* Remove a given section from the post abstract and the rendered UI.
*
Expand Down
7 changes: 2 additions & 5 deletions src/js/models/_markerable.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ export default class Markerable extends Section {
if (!this.markers.length) {
return true;
}
let markerWithLength = this.markers.detect((marker) => {
return !!marker.length;
});
return !markerWithLength;
return this.markers.every(m => m.isBlank);
}

/**
Expand Down Expand Up @@ -149,7 +146,7 @@ export default class Markerable extends Section {

/**
* @return {Array} New markers that match the boundaries of the
* range.
* range. Does not change the existing markers in this section.
*/
markersFor(headOffset, tailOffset) {
const range = {head: {section:this, offset:headOffset},
Expand Down
6 changes: 5 additions & 1 deletion src/js/models/_section.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ export default class Section extends LinkedItem {
return this._tagName;
}

get isBlank() {
throw new Error('`isBlank` must be implemented by subclass');
}

clone() {
throw new Error('clone() must be implemented by subclass');
throw new Error('`clone()` must be implemented by subclass');
}

immediatelyNextMarkerableSection() {
Expand Down
7 changes: 7 additions & 0 deletions src/js/models/list-section.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import LinkedList from '../utils/linked-list';
import { forEach } from '../utils/array-utils';
import { LIST_SECTION_TYPE } from './types';
import Section from './_section';

Expand All @@ -23,6 +24,12 @@ export default class ListSection extends Section {
return this.items.isEmpty;
}

clone() {
let newSection = this.builder.createListSection();
forEach(this.items, i => newSection.items.append(i.clone()));
return newSection;
}

// returns [prevListSection, newMarkupSection, nextListSection]
// prevListSection and nextListSection may be undefined
splitAtListItem(listItem) {
Expand Down
8 changes: 8 additions & 0 deletions src/js/models/marker.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { normalizeTagName } from '../utils/dom-utils';
import { detect, commonItemLength, forEach, filter } from '../utils/array-utils';
import LinkedItem from '../utils/linked-item';

function trim(str) {
return str.replace(/^\s+/, '').replace(/\s+$/, '');
}

const Marker = class Marker extends LinkedItem {
constructor(value='', markups=[]) {
super();
Expand All @@ -22,6 +26,10 @@ const Marker = class Marker extends LinkedItem {
return this.length === 0;
}

get isBlank() {
return trim(this.value).length === 0;
}

get length() {
return this.value.length;
}
Expand Down
25 changes: 18 additions & 7 deletions src/js/models/markup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { normalizeTagName } from '../utils/dom-utils';
import { filterObject } from '../utils/array-utils';
import { MARKUP_TYPE } from './types';
import assert from '../utils/assert';

export const VALID_MARKUP_TAGNAMES = [
'b',
Expand All @@ -10,27 +12,36 @@ export const VALID_MARKUP_TAGNAMES = [
'li'
].map(normalizeTagName);

export const VALID_ATTRIBUTES = [
'href',
'ref'
];

class Markup {
/*
* @param {Object} attributes key-values
*/
constructor(tagName, attributes={}) {
this.tagName = normalizeTagName(tagName);
if (Array.isArray(attributes)) {
throw new Error('Must use attributes object param (not array) to Markup');
}
this.attributes = attributes;

assert('Must use attributes object param (not array) for Markup',
!Array.isArray(attributes));

this.attributes = filterObject(attributes, VALID_ATTRIBUTES);
this.type = MARKUP_TYPE;

if (VALID_MARKUP_TAGNAMES.indexOf(this.tagName) === -1) {
throw new Error(`Cannot create markup of tagName ${tagName}`);
}
assert(`Cannot create markup of tagName ${tagName}`,
VALID_MARKUP_TAGNAMES.indexOf(this.tagName) !== -1);
}

hasTag(tagName) {
return this.tagName === normalizeTagName(tagName);
}

getAttribute(name) {
return this.attributes[name];
}

static isValidElement(element) {
const tagName = normalizeTagName(element.tagName);
return VALID_MARKUP_TAGNAMES.indexOf(tagName) !== -1;
Expand Down
45 changes: 44 additions & 1 deletion src/js/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import LinkedList from 'content-kit-editor/utils/linked-list';
import { forEach, compact } from 'content-kit-editor/utils/array-utils';
import Set from 'content-kit-editor/utils/set';
import { isMarkerable } from 'content-kit-editor/models/_section';
import MobiledocRenderer from 'content-kit-editor/renderers/mobiledoc';

export default class Post {
constructor() {
Expand Down Expand Up @@ -99,6 +100,22 @@ export default class Post {
return markups.toArray();
}

walkAllSections(range, callback) {
const {head, tail} = range;

let currentSection = head.section;

while (currentSection) {
callback(currentSection);

if (currentSection === tail.section) {
break;
} else {
currentSection = currentSection.next;
}
}
}

walkMarkerableSections(range, callback) {
const {head, tail} = range;

Expand Down Expand Up @@ -147,7 +164,8 @@ export default class Post {
return containedSections;
}

// return the next section that has markers after this one
// return the next section that has markers after this one,
// possibly skipping non-markerable sections
_nextMarkerableSection(section) {
if (!section) { return null; }
const hasChildren = s => !!s.items;
Expand Down Expand Up @@ -175,4 +193,29 @@ export default class Post {
}
}
}

/**
* @param {Range} range
* @return {Mobiledoc} A mobiledoc representation of the range (JSON)
*/
cloneRange(range) {
const post = this.builder.createPost();
const { builder } = this;

this.walkAllSections(range, section => {
let newSection;
if (isMarkerable(section)) {
newSection = builder.createMarkupSection(section.tagName);
let currentRange = range.trimTo(section);
forEach(
section.markersFor(currentRange.headSectionOffset, currentRange.tailSectionOffset),
m => newSection.markers.append(m)
);
} else {
newSection = section.clone();
}
post.sections.append(newSection);
});
return MobiledocRenderer.render(post);
}
}
Loading

0 comments on commit c2bbafe

Please sign in to comment.