Commit 07b19071 authored by chris's avatar chris

lists

parent 5cad0e0b
......@@ -2,7 +2,6 @@ import {
BasePackage,
ProseArticle,
Document as SubstanceDocument,
ListPackage,
TablePackage,
} from 'substance'
......@@ -48,11 +47,11 @@ import HeadingPackage from './elements/headings/HeadingPackage'
import SpellCheckTogglePackage from './elements/spellcheck_toggle/SpellCheckTogglePackage'
import ChangeCasePackage from './elements/change_case/ChangeCasePackage'
import QuoteMarksPackage from './elements/quote_mark/QuoteMarksPackage'
import ListPackage from './elements/list/ListPackage'
// import InlineNotePackage from './elements/inline_note/InlineNotePackage'
//TODO Need to recreate them?
// import ChapterNumber from './elements/chapterNumber/ChapterNumberPackage'
// import ListPackage from './elements/list/ListPackage'
const config = {
name: 'simple-editor',
......
import { Command } from 'substance'
export default class IndentListCommand extends Command {
getCommandState(params) {
let editorSession = this._getEditorSession(params)
let doc = editorSession.getDocument()
let sel = this._getSelection(params)
if (sel && sel.isPropertySelection()) {
let path = sel.path
let node = doc.get(path[0])
if (node) {
if (node.isListItem()) {
return {
disabled: false,
}
}
}
}
return { disabled: true }
}
execute(params) {
let commandState = params.commandState
const { disabled } = commandState
if (disabled) return
let editorSession = params.editorSession
let action = this.config.spec.action
switch (action) {
case 'indent': {
editorSession.transaction(
tx => {
tx.indent()
},
{ action: 'indent' },
)
break
}
case 'dedent': {
editorSession.transaction(
tx => {
tx.dedent()
},
{ action: 'dedent' },
)
break
}
default:
//
}
}
}
import { last } from 'lodash'
import { Command } from 'substance'
import {
containsImage,
containsList,
createContainerSelection,
getContainer,
isListItem,
sliceListsAccordingToSelection,
toggleList,
transformListToParagraphs,
transactionHelpers,
} from '../../helpers/ListHelpers'
class InsertListCommand extends Command {
getCommandState(params) {
const commandState = {}
const sel = this._getSelection(params)
const { disableCollapsedCursor } = this.config
const disabledCollapsedCursor = disableCollapsedCursor && sel.isCollapsed()
if (params.surface && !params.surface.context.editor.props.mode.styling) {
commandState.disabled = true
return commandState
}
if (
disabledCollapsedCursor ||
(!sel.isPropertySelection() && !sel.isContainerSelection())
) {
commandState.disabled = true
return commandState
}
if (params.surface && params.surface.container.disableStyling) {
commandState.disabled = true
return commandState
}
// TODO -- remove explicit reference to images / try with isText
const hasImageInSelection = containsImage(params, sel)
const hasListInSelection = containsList(params, sel)
if (hasListInSelection && hasImageInSelection) {
commandState.disabled = true
return commandState
}
return commandState
}
applyMixedTransformations(params, selection) {
const { editorSession } = transactionHelpers(params)
const isStartSelectionListItem = isListItem(params, selection.start.path[0])
const isEndSelectionListItem = isListItem(params, selection.end.path[0])
const lists = sliceListsAccordingToSelection(params, selection)
editorSession.transaction(tx => {
const options = { config: this.config }
const paragraphs = transformListToParagraphs(params, tx, lists, options)
const startPath = isStartSelectionListItem
? paragraphs[0]
: selection.start.path[0]
const endPath = isEndSelectionListItem
? last(paragraphs)
: selection.end.path[0]
const sel = createContainerSelection(
params,
selection,
tx,
startPath,
endPath,
)
const propertySelections = sel.splitIntoPropertySelections()
this.transactionsForPropertyTransformation(params, tx, propertySelections)
})
}
transactionsForPropertyTransformation(params, tx, propertySelections) {
const { containerId, doc } = transactionHelpers(params)
propertySelections.forEach((property, index) => {
tx.setSelection({
type: 'property',
path: property.path,
startOffset: 0,
endOffset: 0,
containerId,
})
if (index === 0) {
const options = { config: this.config }
toggleList(tx, params, options)
} else {
const container = getContainer(params)
const currentPropertyNode = doc.get(property.start.path[0])
const previousPropertyNode = doc.get(
propertySelections[index - 1].start.path[0],
)
// HACK
// Because tx.toggleList does not return the node that has just been
// created, we do not know the node's position in the document.
let currentNodePosition = 1
if (currentPropertyNode) {
currentNodePosition = container.getPosition(currentPropertyNode.id)
}
const previousNode = container.getNodeAt(currentNodePosition - 1)
// TODO -- remove image reference
if (
(previousNode && previousNode.type === 'image') ||
// (previousNode && previousNode.isText) ||
(currentPropertyNode &&
currentPropertyNode.content === '' &&
previousPropertyNode &&
previousPropertyNode.content === '')
) {
const options = { config: this.config }
toggleList(tx, params, options)
return
}
tx.deleteCharacter('left')
tx.break()
}
})
}
applyPropertyTransformations(params, propertySelections) {
const { editorSession } = params
editorSession.transaction(tx => {
this.transactionsForPropertyTransformation(params, tx, propertySelections)
})
}
execute(params) {
const { selection, surface } = transactionHelpers(params)
if (!surface) return
this.applyMixedTransformations(params, selection)
}
getCustomValue() {
const { custom } = this.config
let customValue = null
if (custom) customValue = custom
return customValue
}
}
export default InsertListCommand
import { Tool } from 'substance'
class InsertListTool extends Tool {
constructor(...props) {
super(...props)
this.selectedList = null
}
getClassNames() {
return 'sc-insert-list-tool'
}
didMount() {
this.context.editorSession.onUpdate('', this.checkIfListSelected, this)
}
onClick() {
this.executeCommand({
context: this.context,
})
}
renderButton($$) {
const listType = this.getListType()
const title = this.getTitle()
const button = super.renderButton($$).append(title)
if (this.getCommandName() === listType) {
button.addClass('list-active')
}
if (this.isReadOnlyMode()) {
button.attr('disabled', true)
}
return button
}
getListType() {
const { editorSession } = this.context
const doc = editorSession.getDocument()
const selection = editorSession.getSelection()
if (!selection.isNull() && selection.isPropertySelection()) {
const nodeId = selection.start.path[0]
const node = doc.get(nodeId)
if (node.type === 'list-item') {
return node.parent.listName
}
}
return null
}
checkIfListSelected() {
if (this.getListType() !== this.selectedList) {
this.selectedList = this.getListType()
this.rerender()
}
}
getContainerId() {
const editor = this.getEditor()
return editor.props.containerId
}
getEditor() {
return this.context.editor
}
getSurface() {
const { surfaceManager } = this.context
const containerId = this.getContainerId()
return surfaceManager.getSurface(containerId)
}
isReadOnlyMode() {
const surface = this.getSurface()
if (!surface) return true // HACK -- ???
return surface.isReadOnlyMode()
}
}
export default InsertListTool
/* eslint react/prop-types: 0 */
import { isString, Component } from 'substance'
import { isString, NodeComponent } from 'substance'
import ListItemComponent from './ListItemComponent'
import renderListNode from './renderListNode'
import getListTagName from './getListTagName'
class ListComponent extends Component {
didMount() {
this.context.editorSession.onRender('document', this._onChange, this)
}
export default class ListComponent extends NodeComponent {
render($$) {
let node = this.props.node
let customClass = ''
if (this.props.node.custom) {
customClass = '-' + this.props.node.custom
}
let el = $$(getListTagName(node))
.addClass('sc-list-' + getListTagName(node) + customClass)
.attr('data-id', node.id)
renderListNode(node, el, arg => {
if (isString(arg)) {
return $$(arg)
} else if (arg.type === 'list-item') {
let item = arg
let el = renderListNode(node, item => {
// item is either a list item node, or a tagName
if (isString(item)) {
return $$(item)
} else if (item.type === 'list-item') {
return $$(ListItemComponent, {
path: [item.id, 'content'],
node: item,
tagName: 'li',
})
// setting ref to preserve items when rerendering
// .ref(item.id)
}).ref(item.id)
}
})
el.addClass('sc-list').attr('data-id', node.id)
return el
}
_onChange(change) {
const node = this.props.node
if (change.isAffected(node.id)) {
return this.rerender()
}
// check if any of the list items are affected
let itemIds = node.items
for (let i = 0; i < itemIds.length; i++) {
if (change.isAffected([itemIds[i], 'level'])) {
return this.rerender()
}
}
}
}
// we need this ATM to prevent this being wrapped into an isolated node (see ContainerEditor._renderNode())
ListComponent.prototype._isCustomNodeComponent = true
export default ListComponent
......@@ -2,78 +2,78 @@
import { isString } from 'substance'
import renderListNode from './renderListNode'
import getListTagName from './getListTagName'
import { walk } from 'lodash'
/*
HTML converter for Lists.
*/
export default {
type: 'list',
export default class ListHTMLConverter {
get type() {
return 'list'
}
matchElement(el) {
return el.is('ul') || el.is('ol')
},
}
import(el, node, converter) {
const self = this
if (el.attr('styling')) node.custom = el.attr('styling')
if (el.attr('listName')) node.listName = el.attr('listName')
this.santizeNestedLists(el)
if (el.is('ol')) node.ordered = true
const itemEls = el.findAll('li')
const getLevel = li => {
let element = li
let level = 1
while (element) {
if (element.parentNode === el) return level
element = element.parentNode
if (self.matchElement(element)) level += 1
this._santizeNestedLists(el)
let items = []
let config = []
walk(el, (el, level) => {
if (!el.isElementNode()) return
if (el.is('li')) {
items.push({ el, level })
} else if (!config[level]) {
if (el.is('ul')) config[level] = 'bullet'
else if (el.is('ol')) config[level] = 'order'
}
}
itemEls.forEach(li => {
// ATTENTION: pulling out nested list elements here on-the-fly
const listItem = converter.convertElement(li)
listItem.level = getLevel(li)
node.items.push(listItem.id)
})
},
export(node, el, converter) {
const $$ = converter.$$
el.tagName = getListTagName(node)
if (node.custom) el.attr('styling', node.custom)
if (node.listName) el.attr('listName', node.listName)
renderListNode(node, el, arg => {
if (isString(arg)) return $$(arg)
const item = arg
return $$('li').append(converter.annotatedText(item.getTextPath()))
this._createItems(converter, node, items, config)
}
// this is specific to the node model defined in ListNode
_createItems(converter, node, items, levelTypes) {
node.items = items.map(d => {
let listItem = converter.convertElement(d.el)
listItem.level = d.level
return listItem.id
})
node.listType = levelTypes.join(',')
}
export(node, el, converter) {
let $$ = converter.$$
let _createElement = function(arg) {
if (isString(arg)) {
return $$(arg)
} else {
let item = arg
let path = item.getPath()
return $$('li').append(converter.annotatedText(path))
}
}
let _el = renderListNode(node, _createElement)
el.tagName = _el.tagName
el.attr(_el.getAttributes())
el.append(_el.getChildNodes())
return el
},
santizeNestedLists(root) {
const nestedLists = root.findAll('ol,ul')
}
_santizeNestedLists(root) {
// pulling out uls from <li> to simplify the problem
/*
E.g.
`<ul><li>Foo:<ul>...</ul></li>`
Is turned into:
`<ul><li>Foo:</li><ul>...</ul></ul>`
*/
let nestedLists = root.findAll('ol,ul')
nestedLists.forEach(el => {
while (!el.parentNode.is('ol,ul')) {
// pull it up
const parent = el.parentNode
const grandParent = parent.parentNode
const pos = grandParent.getChildIndex(parent)
let parent = el.parentNode
let grandParent = parent.parentNode
let pos = grandParent.getChildIndex(parent)
grandParent.insertAt(pos + 1, el)
}
})
},
}
}
import { TextPropertyComponent } from 'substance'
import { Component, TextPropertyComponent } from 'substance'
class ListItemComponent extends TextPropertyComponent {}
export default class ListItemComponent extends Component {
render($$) {
const node = this.props.node
const path = node.getPath()
export default ListItemComponent
let el = $$('li').addClass('sc-list-item')
el.append($$(TextPropertyComponent, { path }).ref('text'))
// for nested lists
if (this.props.children) {
el.append(this.props.children)
}
return el
}
}
/*
* HTML converter for Lists.
*/
export default {
type: 'list-item',
......@@ -13,6 +10,6 @@ export default {
},
export: function(node, el, converter) {
el.append(converter.annotatedText(node.getTextPath()))
el.append(converter.annotatedText(node.getPath()))
},
}
import { TextNode } from 'substance'
class ListItem extends TextNode {}
export default class ListItem extends TextNode {
isListItem() {
return true
}
getLevel() {
return this.level
}
setLevel(newLevel) {
if (this.level !== newLevel) {
this.getDocument().set([this.id, 'level'], newLevel)
}
}
}
ListItem.type = 'list-item'
ListItem.schema = {
level: { type: 'number', default: 1 },
}
export default ListItem
import { DocumentNode } from 'substance'
import { DocumentNode, ListMixin } from 'substance'
class ListNode extends DocumentNode {
getItemAt(idx) {
return this.getDocument().get(this.items[idx])
}
getFirstItem() {
return this.getItemAt(0)
}
export default class ListNode extends ListMixin(DocumentNode) {
// specific implementation
getLastItem() {
return this.getItemAt(this.getLength() - 1)
createListItem(text) {
return this.getDocument().create({ type: 'list-item', content: text })
}
getItems() {
......@@ -20,54 +14,53 @@ class ListNode extends DocumentNode {
})
}
getItemPosition(itemId) {
if (itemId._isNode) itemId = itemId.id
let pos = this.items.indexOf(itemId)
if (pos < 0) throw new Error('Item is not within this list: ' + itemId)
return pos
getItemsPath() {
return [this.id, 'items']
}
insertItemAt(pos, itemId) {
insertItemAt(pos, item) {
const doc = this.getDocument()
doc.update([this.id, 'items'], { type: 'insert', pos: pos, value: itemId })
}
appendItem(itemId) {
this.insertItemAt(this.items.length, itemId)
const id = item.id
doc.update(this.getItemsPath(), { type: 'insert', pos: pos, value: id })
}
removeItemAt(pos) {
const doc = this.getDocument()
doc.update([this.id, 'items'], { type: 'delete', pos: pos })
doc.update(this.getItemsPath(), { type: 'delete', pos: pos })
}
remove(itemId) {
const doc = this.getDocument()
const pos = this.getItemPosition(itemId)
if (pos >= 0) {
doc.update([this.id, 'items'], { type: 'delete', pos: pos })
}
// overridden
getItemAt(idx) {
return this.getDocument().get(this.items[idx])
}
getItemPosition(item) {
const id = item.id
let pos = this.items.indexOf(id)
if (pos < 0) throw new Error('Item is not within this list: ' + id)
return pos
}
getLength() {
return this.items.length
}
get length() {
return this.getLength()
getListTypeString() {
return this.listType
}
}
ListNode.isList = true
setListTypeString(levelTypeStr) {
this.getDocument().set([this.id, 'listType'], levelTypeStr)
}
}
ListNode.type = 'list'
ListNode.schema = {
ordered: { type: 'boolean', default: false },
custom: { type: 'string', optional: true },
listName: { type: 'string', optional: true },
// list-items are owned by the list
// this means, if the list gets deleted, the list items
// will be deleted too
items: { type: ['array', 'id'], default: [], owned: true },