diff --git a/builds/demo/index.html b/builds/demo/index.html index 27404acae..80f447cb8 100644 --- a/builds/demo/index.html +++ b/builds/demo/index.html @@ -5,21 +5,21 @@ Texture Editor - + - + diff --git a/docs/TextureAPI.md b/docs/TextureAPI.md deleted file mode 100644 index d3c59e105..000000000 --- a/docs/TextureAPI.md +++ /dev/null @@ -1,347 +0,0 @@ -# Texture API - -A Javascript API to modify content in a Texture article. This is particularly useful for collecting metadata, such as authors, affiliations, references, keywords etc. This is a format-agnostic abstraction. So it means the data can be serialised in different ways, so Texture is not tightly bound to JATS. We could also use HTML + JSON to represent an article and its metadata. - -* [Affiliations](#affiliations) -* [Authors](#authors) -* [Editors](#editors) -* [Awards](#awards) -* [References](#references) -* [Keywords](#keywords) -* [Subjects](#subjects) - -## Contribs - -Manage contributor data, such as authors, editors, affiliations, awards. - -```js -let contribs = api.getContribsModel() -``` - -### Affiliations - -Print affiliations, depending on the order of authors. If you haven't assigned any affiliations, this list will be empty. - -```js -contribs.getAffiliations() -``` - -```js -[{ id: 'aff1', ... }, ...] -``` - -Add new affiliation: - -```js -contribs.addAffiliation({ - id: 'aff1', - name: 'German Primate Center GmbH', - division1: 'Neurobiology Laboratory', - city: 'Göttingen', - country: 'Germany' -}) -``` - -Get affiliation: - -```js -contribs.getAffiliation('aff1') -``` - -Update an affiliation: - -```js -contribs.updateAffiliation('aff1', {...}) -``` - -Remove an affiliation: - -```js -contribs.deleteAffiliation('aff1') -``` - -### Authors - -Get all authors in order: - -```js -contribs.getAuthors() -``` - - -```js -[ - { - id: 'author1', - type: 'person', - surname: 'Schaffelhofer', - givenNames: 'Stefan', - suffix: 'Phd', - email: 'stefan@schaffelhofer.com', - // affiliations related to this paper - affiliations: ['org1'], - presentAffiliation: ['org1'], - // awards related to this paper - awards: ['fund1'], - equalContrib: true, - corresp: true, - }, - // Groups are considered one independent entity (not reusing person entry for members) - // When updating via API, a whole new record is written - { - id: 'author2', - type: 'group', - affiliations: ['org2'], - presentAffiliation: ['org2'], - awards: ['fund1'], - members: [ - { - surname: 'Kelly', - givenNames: 'Laura A.', - email: 'stefan@schaffelhofer.com', - affiliations: ['org2'], - awards: ['fund1'], - role: 'Writing Group' - }, - { - surname: 'Randall', - givenNames: 'Daniel Lee', - suffix: 'Jr.', - email: 'stefan@schaffelhofer.com', - affiliations: ['org3'], - awards: ['fund1'], - role: 'Lab Group' - } - ] - } -] -``` - -To add a new author: - -```js -contribs.addAuthor({...}) -``` - -Update an author: - -```js -contribs.updateAuthor('author1', {...}) -``` - -Delete an author: - -```js -contribs.deleteAuthor('author3') -``` - -To change the position of an author in the author list: - -```js -contribs.moveAuthor('author4', 0) // move to position 0 -``` - -### Editors - -Pretty much the same as with authors, except there's no type attribute needed. Use the following methods: - -```js -contribs.getEditors(data) -contribs.addEditor(data) -contribs.updateEditor(id, data) -contribs.moveEditor(id, pos) -contribs.deleteEditor(id) -``` - -### Awards - -Add an award (grant), which can then be referenced from an author using the `awards` property. - -```js -contribs.addAward({ - id: 'fund1', - institution: 'Howard Huges Medical Institute', - awardId: 'F32 GM089018' -}) -``` - -## References - -```js -let references = this.context.api.getReferences() -``` - -### Get Reference - -```js -references.getReference('r1') -``` - -Result: - -```js -{ - "type": "book", - "id": "r1", - "authors": [ - { - "givenNames": "JA", - "surname": "Coyne" - }, - { - "givenNames": "HA", - "surname": "Orr" - } - ], - "translators": [], - "title": "Speciation and its consequences", - "volume": "", - "edition": "", - "publisherLoc": "Sunderland, MA", - "publisherName": "Sinauer Associates", - "year": "1989" -} -``` - -### Add Reference - -```js -references.addReference({ - id: "r2", - type: "journal-article", - "title": "....", - ... -}) -``` - -### Update a Reference - -```js -references.updateReference('r2', { - "title": "....", - ... -}) -``` - -### Get label for a reference: - -```js -references.getLabel('r2') // => e.g. [2] -``` - -### Render reference - -Returns the rendered HTML string (without label) - -```js -references.renderReference('r2') -``` - - -### Get bibliography - -```js -references.getBibliography() -``` - -Result: - -```js -[ - { - label: '[1]', - data: {id: 'r1', type: 'book', ...} - } -] -``` - - -## Metadata - -```js -let meta = api.getMeta() -``` - -### Keywords - -Add a keyword: - -```js -meta.addKeyword('optogenetics', 'author-keyword') -meta.addKeyword('two-photon', 'author-keyword') -meta.addKeyword('Mouse', 'research-organism') -``` - -List all available keyword categories: - -```js -meta.getKeywordCategories() -``` - -Result: - -```js -['author-keyword', 'research-organism'] -``` - -List keywords for a given category: - -```js -meta.getKeywords('author-keyword') -``` - -```js -['optogenetics', 'two-photon'] -``` - -### Subjects - -Add a subject: - -```js -meta.addSubject('Research Article', 'article-type') -meta.addSubject('Computational and Systems Biology', 'research-subject') -meta.addSubject('Epidemiology and Global Health', 'research-subject') -``` - -List all available keyword categories: - -```js -meta.getSubjectCategories() -``` - -Result: - -```js -['article-type', 'research-subject'] -``` - -List keywords for a given category: - -```js -meta.getSubjects('research-subject') -``` - -```js -['Computational and Systems Biology', 'Epidemiology and Global Health'] -``` - -### Publication Dates - -Set or overwrite publication date (month and day are optional) - -```js -meta.setPubDate(2016, 3, 1) -``` - -Add publication history record: - -```js -meta.addPubHistoryRecord('received', 2016, 3, 1) -``` - -Remove publication history record: - -```js -meta.clearPubHistoryRecord('received') -``` diff --git a/make.js b/make.js index 22f011991..5a06adf83 100644 --- a/make.js +++ b/make.js @@ -104,6 +104,7 @@ b.task('build:assets', function () { b.copy('./node_modules/inter-ui', DIST + 'lib/inter-ui') b.copy('./node_modules/katex/dist', DIST + 'lib/katex') b.copy('./node_modules/substance/dist/*.css*', DIST + 'lib/substance/') + b.copy('./node_modules/substance/dist/substance.js*', DIST + 'lib/substance/') b.copy('./node_modules/substance/dist/substance.min.js*', DIST + 'lib/substance/') b.copy('./node_modules/texture-plugin-jats/dist', DIST + 'plugins/texture-plugin-jats') b.css('texture.css', DIST + 'texture.css') diff --git a/src/article/converter/jats/FigurePanelConverter.js b/src/article/converter/jats/FigurePanelConverter.js index 8095be804..c91f5e1cc 100644 --- a/src/article/converter/jats/FigurePanelConverter.js +++ b/src/article/converter/jats/FigurePanelConverter.js @@ -59,11 +59,16 @@ export default class FigurePanelConverter { let kwdEls = kwdGroupEl.findAll('kwd') let labelEl = kwdGroupEl.find('label') let name = labelEl ? labelEl.textContent : '' - let value = kwdEls.map(kwdEl => kwdEl.textContent).join(', ') + let values = kwdEls.map(kwdEl => { + return doc.create({ + type: 'custom-metadata-value', + content: kwdEl.textContent + }).id + }) return doc.create({ type: 'custom-metadata-field', name, - value + values }).id }) } @@ -109,8 +114,8 @@ export default class FigurePanelConverter { let kwdGroupEl = $$('kwd-group').append( $$('label').text(field.name) ) - let kwdEls = field.value.split(',').map(str => { - return $$('kwd').text(str.trim()) + let kwdEls = field.resolve('values').map(keyword => { + return $$('kwd').text(keyword.content) }) kwdGroupEl.append(kwdEls) return kwdGroupEl diff --git a/src/article/manuscript/ManuscriptPackage.js b/src/article/manuscript/ManuscriptPackage.js index 2780e58a7..184ef759f 100644 --- a/src/article/manuscript/ManuscriptPackage.js +++ b/src/article/manuscript/ManuscriptPackage.js @@ -57,6 +57,7 @@ import { import InsertTableTool from './InsertTableTool' import OpenFigurePanelImageTool from '../shared/OpenFigurePanelImageTool' import RemoveItemCommand from '../shared/RemoveItemCommand' +import RemoveKeywordCommand from '../shared/RemoveKeywordCommand' import ReplaceFigurePanelTool from '../shared/ReplaceFigurePanelTool' import ReplaceSupplementaryFileCommand from './ReplaceSupplementaryFileCommand' import ReplaceSupplementaryFileTool from './ReplaceSupplementaryFileTool' @@ -241,6 +242,9 @@ export default { nodeType: 'footnote', commandGroup: 'footnote' }) + config.addCommand('remove-metadata-keyword', RemoveKeywordCommand, { + commandGroup: 'custom-metadata-fields' + }) config.addCommand('replace-figure-panel-image', ReplaceFigurePanelImageCommand, { commandGroup: 'figure-panel' }) @@ -306,6 +310,7 @@ export default { config.addLabel('article-info', 'Article Information') config.addLabel('article-record', 'Article Record') config.addLabel('contributors', 'Authors & Contributors') + config.addLabel('create', 'Create') config.addLabel('create-unordered-list', 'Bulleted list') config.addLabel('create-ordered-list', 'Numbered list') config.addLabel('edit-ref', 'Edit Reference') @@ -332,6 +337,9 @@ export default { config.addLabel('enter-custom-field-value', 'Enter value') config.addLabel('add-action', 'Add') config.addLabel('enter-url-placeholder', 'Enter url') + config.addLabel('enter-keyword', 'Enter keyword') + config.addLabel('enter-keywords', 'Click to add keywords') + config.addLabel('edit-keywords', 'Edit keywords') // Icons config.addIcon('create-unordered-list', { 'fontawesome': 'fa-list-ul' }) @@ -447,6 +455,7 @@ export default { // KeyboardShortcuts config.addKeyboardShortcut('CommandOrControl+a', { command: 'table:select-all' }) + config.addKeyboardShortcut('CommandOrControl+Delete', { command: 'remove-metadata-keyword' }) // Register commands and keyboard shortcuts for collections registerCollectionCommand(config, 'author', ['metadata', 'authors'], { keyboardShortcut: 'CommandOrControl+Alt+A', nodeType: 'person' }) diff --git a/src/article/metadata/MetadataPackage.js b/src/article/metadata/MetadataPackage.js index 7ef504105..ff8a2f0db 100644 --- a/src/article/metadata/MetadataPackage.js +++ b/src/article/metadata/MetadataPackage.js @@ -36,6 +36,7 @@ import { TableSelectAllCommand, ToggleCellHeadingCommand, ToggleCellMergeCommand } from '../manuscript/TableCommands' import OpenFigurePanelImageTool from '../shared/OpenFigurePanelImageTool' +import RemoveKeywordCommand from '../shared/RemoveKeywordCommand' import ReplaceFigurePanelTool from '../shared/ReplaceFigurePanelTool' import TableFigureComponent from '../shared/TableFigureComponent' import TranslatableEntryEditor from './TranslatableEntryEditor' @@ -243,6 +244,9 @@ export default { config.addCommand('remove-figure-panel', RemoveFigurePanelCommand, { commandGroup: 'figure-panel' }) + config.addCommand('remove-metadata-keyword', RemoveKeywordCommand, { + commandGroup: 'custom-metadata-fields' + }) config.addCommand('remove-reference', RemoveReferenceCommand, { commandGroup: 'reference' }) @@ -318,6 +322,7 @@ export default { config.addKeyboardShortcut('CommandOrControl+Alt+Up', { command: 'move-up-col-item' }) config.addKeyboardShortcut('CommandOrControl+Alt+Down', { command: 'move-down-col-item' }) config.addKeyboardShortcut('CommandOrControl+Alt+Delete', { command: 'remove-col-item' }) + config.addKeyboardShortcut('CommandOrControl+Delete', { command: 'remove-metadata-keyword' }) // Labels config.addLabel('abstracts', 'Abstracts') @@ -359,11 +364,16 @@ export default { config.addLabel('subtitle', 'Subtitle') config.addLabel('empty-figure-metadata', 'No fields specified') config.addLabel('open-link', 'Open Link') + config.addLabel('enter-keyword', 'Enter keyword') + config.addLabel('enter-keywords', 'Click to add keywords') + config.addLabel('edit-keywords', 'Edit keywords') + // Icons config.addIcon('input-error', { 'fontawesome': 'fa-exclamation-circle' }) config.addIcon('input-loading', { 'fontawesome': 'fa-spinner fa-spin' }) config.addIcon('move-down-figure-panel', { 'fontawesome': 'fa-caret-square-o-down' }) config.addIcon('open-link', { 'fontawesome': 'fa-external-link' }) + config.addIcon('trash', { 'fontawesome': 'fa-trash' }) // TODO: need to rethink this a some point registerCollectionCommand(config, 'author', ['metadata', 'authors'], { keyboardShortcut: 'CommandOrControl+Alt+A', nodeType: 'person' }) diff --git a/src/article/models/ArticleModelPackage.js b/src/article/models/ArticleModelPackage.js index 1fb437e70..51b73f22f 100644 --- a/src/article/models/ArticleModelPackage.js +++ b/src/article/models/ArticleModelPackage.js @@ -11,6 +11,7 @@ import ChapterRef from './ChapterRef' import ConferencePaperRef from './ConferencePaperRef' import CustomAbstract from './CustomAbstract' import CustomMetadataField from './CustomMetadataField' +import CustomMetadataValue from './CustomMetadataValue' import DataPublicationRef from './DataPublicationRef' import ExternalLink from './ExternalLink' import Figure from './Figure' @@ -65,7 +66,7 @@ export default { ;[ Abstract, Article, ArticleRef, BlockFormula, BlockQuote, Body, Bold, BookRef, Break, ChapterRef, ConferencePaperRef, - CustomAbstract, CustomMetadataField, DataPublicationRef, ExternalLink, Figure, FigurePanel, + CustomAbstract, CustomMetadataField, CustomMetadataValue, DataPublicationRef, ExternalLink, Figure, FigurePanel, Footnote, Funder, Graphic, Group, Heading, InlineFormula, InlineGraphic, Italic, Keyword, JournalArticleRef, List, ListItem, MagazineArticleRef, Metadata, Monospace, NewspaperArticleRef, Organisation, Overline, Paragraph, PatentRef, Permission, diff --git a/src/article/models/CustomMetadataField.js b/src/article/models/CustomMetadataField.js index 534a0e83f..7232222e8 100644 --- a/src/article/models/CustomMetadataField.js +++ b/src/article/models/CustomMetadataField.js @@ -1,4 +1,4 @@ -import { DocumentNode, STRING } from 'substance' +import { CHILDREN, DocumentNode, STRING } from 'substance' export default class CustomMetadataField extends DocumentNode { static getTemplate () { @@ -14,8 +14,5 @@ export default class CustomMetadataField extends DocumentNode { CustomMetadataField.schema = { type: 'custom-metadata-field', name: STRING, - // ATTENTION: for now a field consist only of one plain-text value - // user may use ',' to separate values - // later on we might opt for a structural approach - value: STRING + values: CHILDREN('custom-metadata-value') } diff --git a/src/article/models/CustomMetadataValue.js b/src/article/models/CustomMetadataValue.js new file mode 100644 index 000000000..0b9d60bc8 --- /dev/null +++ b/src/article/models/CustomMetadataValue.js @@ -0,0 +1,17 @@ +import { DocumentNode, STRING } from 'substance' + +export default class CustomMetadataValue extends DocumentNode { + static getTemplate () { + return { + type: 'custom-metadata-value' + } + } + + isEmpty () { + return this.length === 0 + } +} +CustomMetadataValue.schema = { + type: 'custom-metadata-value', + content: STRING +} diff --git a/src/article/models/index.js b/src/article/models/index.js index 22b703a89..a4529027d 100644 --- a/src/article/models/index.js +++ b/src/article/models/index.js @@ -11,6 +11,7 @@ export { default as ChapterRef } from './ChapterRef' export { default as ConferencePaperRef } from './ConferencePaperRef' export { default as CustomAbstract } from './CustomAbstract' export { default as CustomMetadataField } from './CustomMetadataField' +export { default as CustomMetadataValue } from './CustomMetadataValue' export { default as DataPublicationRef } from './DataPublicationRef' export { default as ExternalLink } from './ExternalLink' export { default as Figure } from './Figure' diff --git a/src/article/shared/ArticleToolbarPackage.js b/src/article/shared/ArticleToolbarPackage.js index acd5b1995..692c8a036 100644 --- a/src/article/shared/ArticleToolbarPackage.js +++ b/src/article/shared/ArticleToolbarPackage.js @@ -373,6 +373,7 @@ export default { config.addLabel('move-down-metadata-field', 'Move Down Metadata Field') config.addLabel('move-up-metadata-field', 'Move Up Metadata Field') config.addLabel('remove-metadata-field', 'Remove Metadata Field') + config.addLabel('remove-metadata-keyword', 'Remove Keyword') // Author tools config.addLabel('edit-author', 'Edit Author') // Reference tools diff --git a/src/article/shared/CustomMetadataFieldComponent.js b/src/article/shared/CustomMetadataFieldComponent.js index 6e4da3215..f3908a698 100644 --- a/src/article/shared/CustomMetadataFieldComponent.js +++ b/src/article/shared/CustomMetadataFieldComponent.js @@ -1,12 +1,44 @@ -import { NodeComponent } from '../../kit' +import { createValueModel, NodeComponent } from '../../kit' +import KeywordInput from './KeywordInput' export default class CustomMetadataFieldComponent extends NodeComponent { + getActionHandlers () { + return { + addValue: this._addValue + } + } + render ($$) { let el = $$('div').addClass('sc-custom-metadata-field') - el.append( - this._renderValue($$, 'name', { placeholder: this.getLabel('enter-custom-field-name') }).addClass('se-field-name'), - this._renderValue($$, 'value', { placeholder: this.getLabel('enter-custom-field-value') }) - ) + const node = this._getNode() + const valuesModel = this._getValuesModel() + + if (this.context.editable) { + el.append( + this._renderValue($$, 'name', { placeholder: this.getLabel('enter-custom-field-name') }).addClass('se-field-name'), + $$(KeywordInput, { + model: valuesModel, + placeholder: this.getLabel('enter-keywords'), + label: this.getLabel('edit-keywords'), + overlayId: valuesModel.id + }).addClass('se-field-values') + ) + } else { + el.append( + $$('div').addClass('se-field-name').append(node.name), + $$('div').addClass('se-field-values').append(valuesModel.getValue()) + ) + } return el } + + _addValue (value) { + const node = this._getNode() + this.context.api._appendChild([node.id, 'values'], { type: 'custom-metadata-value', content: value }) + } + + _getValuesModel () { + const node = this._getNode() + return createValueModel(this.context.api, [node.id, 'values']) + } } diff --git a/src/article/shared/FigureMetadataComponent.js b/src/article/shared/FigureMetadataComponent.js index 37ba37a9b..a92136f04 100644 --- a/src/article/shared/FigureMetadataComponent.js +++ b/src/article/shared/FigureMetadataComponent.js @@ -15,7 +15,7 @@ export default class FigureMetadataComponent extends ValueComponent { } _renderMetadataField ($$, metadataField) { - let MetdataFieldComponent = this.getComponent(metadataField.type) - return $$(MetdataFieldComponent, { node: metadataField }).ref(metadataField.id) + let MetadataFieldComponent = this.getComponent(metadataField.type) + return $$(MetadataFieldComponent, { node: metadataField }).ref(metadataField.id) } } diff --git a/src/article/shared/KeywordInput.js b/src/article/shared/KeywordInput.js new file mode 100644 index 000000000..e19514473 --- /dev/null +++ b/src/article/shared/KeywordInput.js @@ -0,0 +1,318 @@ +import { CustomSurface, getKeyForPath, domHelpers, keys, last } from 'substance' +import { Popup, TextInput } from '../../kit' + +/** + * Experimental: this is the first example of an overlay that itself + * hosts Surfaces, similar to an IsolatedNodeComponent. + * Thus we are applying the same strategy regarding selections and surfaceId. + */ +export default class KeywordInput extends CustomSurface { + // HACK: keyboard handling between TextInputs and the + // native input is pretty hacky, and should be approached differently + getActionHandlers () { + return { + select: this._select + } + } + + getInitialState () { + return { + isSelected: false, + isExpanded: false + } + } + + didMount () { + super.didMount() + + let appState = this.context.appState + appState.addObserver(['selection'], this._onSelectionHasChanged, this, { stage: 'render' }) + } + + dispose () { + super.dispose() + + this.context.appState.removeObserver(this) + } + + render ($$) { + const editorSession = this.context.editorSession + const doc = editorSession.getDocument() + const model = this.props.model + const values = model.getValue() + const isEmpty = values.length === 0 + const isSelected = this.state.isSelected + const isExpanded = this.state.isExpanded + const label = isEmpty ? this.props.placeholder : values.map(v => { + return doc.get([v, 'content']) + }).join(', ') + + const el = $$('div').addClass('sc-keyword-input') + if (isEmpty) el.addClass('sm-empty') + if (isSelected) { + el.addClass('sm-active') + } + el.addClass(isExpanded ? 'sm-expanded' : 'sm-collapsed') + el.append( + $$('div').addClass('se-label').text(label) + .on('click', this._onClick) + ) + if (isExpanded) { + el.append( + this._renderEditor($$) + ) + } + el.on('mousedown', domHelpers.stopAndPrevent) + .on('mouseup', domHelpers.stopAndPrevent) + .on('click', domHelpers.stopAndPrevent) + .on('dblclick', domHelpers.stopAndPrevent) + .on('keydown', this._onKeydown) + + return el + } + + _renderEditor ($$) { + const model = this.props.model + const metadataValues = model.getItems() + const label = this.props.label + const placeholder = this.getLabel('enter-keyword') + + const Button = this.getComponent('button') + const Input = this.getComponent('input') + + const editorEl = $$('div').ref('editor').addClass('se-keyword-editor') + let lastIdx = metadataValues.length - 1 + metadataValues.forEach((value, idx) => { + const path = [value.id, 'content'] + const name = getKeyForPath(path) + let isFirst = idx === 0 + let isLast = idx === lastIdx + let input = $$(_HackedTextInput, { + name, + path, + placeholder, + isFirst, + isLast + }).ref(value.id) + editorEl.append( + $$('div').addClass('se-keyword').append( + input + ) + ) + }) + editorEl.append( + $$('div').addClass('se-new-keyword-input').append( + $$(Input, { + placeholder, + handleEscape: false + }).ref('newKeywordInput') + .attr({ tabindex: '2' }) + .on('keydown', this._onNewKeywordKeydown) + .on('click', this._onNewKeywordClick), + $$(Button).append( + this.getLabel('create') + ).addClass('se-create-value') + .on('click', this._addKeyword) + ) + ) + return $$(Popup, { label }).append(editorEl) + } + + _renderIcon ($$, iconName) { + return $$('div').addClass('se-icon').append( + this.context.iconProvider.renderIcon($$, iconName) + ) + } + + _getCustomResourceId () { + return this.props.model.id + } + + _onClick (event) { + domHelpers.stopAndPrevent(event) + if (!this.state.isSelected || !this.state.isExpanded) { + this._select(true) + } else if (this.state.isExpanded) { + this._select(false) + } + } + + _onKeydown (event) { + // console.log('### _onKeydown()', event) + event.stopPropagation() + if (event.keyCode === keys.ESCAPE) { + event.preventDefault() + this._ensureIsCollapsed() + } + } + + _onNewKeywordClick (event) { + domHelpers.stopAndPrevent(event) + this._select(true) + } + + _onNewKeywordKeydown (event) { + // console.log('### _onNewKeywordKeydown()', event) + if (event.keyCode === keys.ENTER) { + event.stopPropagation() + event.preventDefault() + this._addKeyword() + this._select(true) + } else if (event.keyCode === keys.UP) { + event.stopPropagation() + event.preventDefault() + this._selectLast() + } else if (event.keyCode === keys.TAB) { + event.stopPropagation() + event.preventDefault() + if (event.shiftKey) { + this._selectLast() + } + } else if (event.keyCode === keys.ESCAPE) { + event.preventDefault() + this.refs.newKeywordInput.val('') + } + } + + _onSelectionHasChanged (sel) { + let surfaceId = sel.surfaceId + if (surfaceId && surfaceId.startsWith(this._surfaceId)) { + if (sel.isCustomSelection()) { + let isExpanded = sel.data.isExpanded + if (!this.state.isSelected) { + this.extendState({ + isSelected: true, + isExpanded + }) + } else if (this.state.isExpanded !== isExpanded) { + this.extendState({ + isExpanded + }) + } + } else { + this._ensureIsExpanded() + } + } else if (this.state.isSelected) { + this.extendState({ isSelected: false, isExpanded: false }) + } + } + + rerenderDOMSelection () { + let sel = this.context.appState.selection + if (sel.isCustomSelection()) { + if (sel.data.isExpanded) { + this._ensureIsExpanded() + this._focusNewKeyworkInput() + } else { + this._ensureIsCollapsed() + } + } else { + this._ensureIsExpanded() + this.context.parentSurface.rerenderDOMSelection() + } + } + + _ensureIsExpanded () { + if (!this.state.isExpanded) { + this.extendState({ + isExpanded: true + }) + } + } + + _ensureIsCollapsed () { + if (this.state.isExpanded) { + this.extendState({ + isExpanded: false + }) + } + } + + _addKeyword () { + const keyword = this.refs.newKeywordInput.val() + this.refs.newKeywordInput.val('') + if (keyword) { + this.send('addValue', keyword) + } + } + + _focusNewKeyworkInput () { + if (this.state.isExpanded) { + this.refs['newKeywordInput'].focus() + } + } + + _select (isExpanded) { + const model = this.props.model + this.context.editorSession.setSelection({ + type: 'custom', + customType: 'keywordInput', + nodeId: model._path[0], + data: { isExpanded }, + surfaceId: this._surfaceId + }) + } + + _selectLast () { + let model = this.props.model + let ids = model.getValue() + let lastId = last(ids) + let surface = this.refs[lastId] + if (surface) { + this.context.editorSession.setSelection({ + type: 'property', + path: surface.props.path, + startOffset: 0, + surfaceId: surface.getSurfaceId() + }) + } + } +} + +// TODO: try to make TextInput better customizable so +// that it is easier to override specific keyboard handlers +// The biggest problem we have is, that some fields are Surfaces +// and the one for the new keyword is just a plain input. +// I tried using a Surface for that too, but this requires the +// value to exist in the model. Maybe, it would still be interesting +// create something that is Surface compatible, but does not need +// a real value in the model, or allows to bind it to a 'volatile' one. +class _HackedTextInput extends TextInput { + _handleTabKey (event) { + event.stopPropagation() + if (this.props.isLast) { + if (event.shiftKey) { + this.__handleTab(event) + } else { + event.preventDefault() + this._select(true) + } + } else if (this.props.isFirst) { + if (event.shiftKey) { + event.preventDefault() + } else { + this.__handleTab(event) + } + } else { + this.__handleTab(event) + } + } + + _handleUpOrDownArrowKey (event) { + event.stopPropagation() + if (this.props.isLast && event.keyCode === keys.DOWN) { + // skip so that the cursor stays within this overlay + event.preventDefault() + this._select(true) + } else if (this.props.isFirst && event.keyCode === keys.UP) { + // skip so that the cursor stays within this overlay + event.preventDefault() + } else { + super._handleUpOrDownArrowKey(event) + } + } + + _select (isExpanded) { + this.send('select', isExpanded) + } +} diff --git a/src/article/shared/RemoveKeywordCommand.js b/src/article/shared/RemoveKeywordCommand.js new file mode 100644 index 000000000..4d19c3386 --- /dev/null +++ b/src/article/shared/RemoveKeywordCommand.js @@ -0,0 +1,30 @@ +import { Command, documentHelpers } from 'substance' + +export default class RemoveKeywordCommand extends Command { + getCommandState (params, context) { + const xpath = params.selectionState.xpath + if (xpath.length > 0) { + const selectedType = xpath[xpath.length - 1].type + if (selectedType === 'custom-metadata-value') { + return { disabled: false } + } + } + return { disabled: true } + } + + execute (params, context) { + const selectionState = params.selectionState + const node = selectionState.node + const nodeId = node.id + const parentNode = node.getParent() + const path = [parentNode.id, 'values'] + const editorSession = context.editorSession + + editorSession.transaction(tx => { + const index = tx.get(path).indexOf(nodeId) + documentHelpers.removeAt(tx, path, index) + documentHelpers.deepDeleteNode(nodeId) + tx.setSelection(null) + }) + } +} diff --git a/src/article/shared/styles/_custom-metadata-field.css b/src/article/shared/styles/_custom-metadata-field.css index bd46e46d8..c207b25a3 100644 --- a/src/article/shared/styles/_custom-metadata-field.css +++ b/src/article/shared/styles/_custom-metadata-field.css @@ -4,7 +4,8 @@ font-size: var(--t-small-font-size); } -.sc-custom-metadata-field > .sc-string { +.sc-custom-metadata-field > .sc-string, +.sc-custom-metadata-field > .sc-keyword-input { flex: 1; } diff --git a/src/article/shared/styles/_index.css b/src/article/shared/styles/_index.css index 2833b0440..db5fc29f0 100644 --- a/src/article/shared/styles/_index.css +++ b/src/article/shared/styles/_index.css @@ -9,7 +9,6 @@ @import './_inline-formula.css'; @import './_manuscript-view.css'; @import './_manuscript.css'; -@import './_multi-select-input.css'; @import './_query.css'; @import './_ref-contrib-editor.css'; @import './_section-label.css'; diff --git a/src/kit/app/EditorSession.js b/src/kit/app/EditorSession.js index d5052f074..3dbb70aa9 100644 --- a/src/kit/app/EditorSession.js +++ b/src/kit/app/EditorSession.js @@ -208,7 +208,21 @@ export default class EditorSession extends AbstractEditorSession { this.editorState.set('overlayId', valueId) } } else { - this.editorState.set('overlayId', null) + // EXPERIMENTAL: OverlayMixin leaves context.overlay + // which can be used to detect if the focused surface is inside an overlay + let focusedSurface = this.getSurface(sel.surfaceId) + if (focusedSurface) { + let overlay = focusedSurface.context.overlay + if (overlay) { + if (overlayId !== overlay._getOverlayId()) { + this.editorState.set('overlayId', overlay._getOverlayId()) + } + } else { + this.editorState.set('overlayId', null) + } + } else { + this.editorState.set('overlayId', null) + } } } } diff --git a/src/kit/styles/_index.css b/src/kit/styles/_index.css index 7e1157376..e81400d9b 100644 --- a/src/kit/styles/_index.css +++ b/src/kit/styles/_index.css @@ -3,9 +3,12 @@ @import './_find-and-replace.css'; @import './_input-with-button.css'; @import './_isolated-node.css'; +@import './_keyword-input.css'; +@import './_multi-select-input.css'; @import './_overlay-canvas.css'; @import './_pinned-msg.css'; @import './_placeholder.css'; +@import './_popup.css'; @import './_scroll-pane.css'; @import './_surface.css'; @import './_text-input.css'; diff --git a/src/kit/styles/_keyword-input.css b/src/kit/styles/_keyword-input.css new file mode 100644 index 000000000..c6f0ac796 --- /dev/null +++ b/src/kit/styles/_keyword-input.css @@ -0,0 +1,58 @@ +.sc-keyword-input { + position: relative; + line-height: var(--t-input-line-height); + cursor: pointer; + padding: var(--t-input-padding); + margin: var(--t-negative-input-padding); + border-radius: var(--t-border-radius); + border: var(--t-input-default-border); +} + +.sc-keyword-input .se-keyword-editor { + padding: var(--t-half-spacing) var(--t-default-spacing); +} + +.sc-keyword-input.sm-active { + border: var(--t-input-focus-border); + background: var(--t-focus-background-color); +} + +.sc-keyword-input .sc-text-input .se-input { + width: auto; +} + +.sc-keyword-input .se-keyword-editor .se-keyword { + margin: var(--t-half-spacing) 0; +} + +.sc-keyword-input .se-new-keyword-input { + display: flex; + margin: 0 var(--t-negative-input-padding); +} + +.sc-keyword-input .se-new-keyword-input > .sc-input { + width: 100%; + border: var(--t-input-default-border); + border-radius: var(--t-border-radius); + padding: var(--t-input-padding); + margin-right: var(--t-half-spacing); + font-size: var(--t-small-font-size); + line-height: var(--t-input-line-height); + flex-grow: 1; +} + +.sc-keyword-input .se-new-keyword-input > .sc-input:focus { + border: var(--t-input-focus-border); +} + +.sc-keyword-input .se-new-keyword-input > .sc-input::placeholder { + color: var(--t-placeholder-text-color); + font-weight: var(--t-normal-font-weight); +} + +.sc-keyword-input .se-create-value { + background: var(--t-action-background-color); + color: var(--t-inverted-text-color); + border-radius: var(--t-border-radius); + padding: var(--t-input-padding) var(--t-half-spacing); +} \ No newline at end of file diff --git a/src/article/shared/styles/_multi-select-input.css b/src/kit/styles/_multi-select-input.css similarity index 50% rename from src/article/shared/styles/_multi-select-input.css rename to src/kit/styles/_multi-select-input.css index cb7d609b8..82ac6a80e 100644 --- a/src/article/shared/styles/_multi-select-input.css +++ b/src/kit/styles/_multi-select-input.css @@ -1,4 +1,5 @@ .sc-multi-select-input { + position: relative; width: 100%; line-height: var(--t-input-line-height); cursor: pointer; @@ -10,7 +11,7 @@ .sc-multi-select-input.sm-active { border: var(--t-input-focus-border); - background: #218df312; + background: var(--t-focus-background-color); } .sc-multi-select-input.sm-empty .se-label { @@ -19,22 +20,7 @@ } .sc-multi-select-input .se-select-editor { - position: absolute; - top: 55px; - left: 200px; - background: #fff; - border: 1px solid #8a8a8a; - padding: 10px 20px; - max-width: 100%; - box-shadow: 0px 3px 7px 1px #777; - z-index: 99; -} - -.sc-multi-select-input .se-select-editor .se-select-label { - text-align: center; - font-size: 12px; - color: #8a8a8a; - margin-bottom: 10px; + padding: var(--t-half-spacing) var(--t-default-spacing); } .sc-multi-select-input .se-select-editor .se-select-item { @@ -48,27 +34,5 @@ } .sc-multi-select-input .se-select-editor .se-select-item .se-item-label { - margin-left: 10px; -} - -.sc-multi-select-input .se-select-editor .se-arrow { - width: 60px; - height: 16px; - overflow: hidden; - position: absolute; - left: 50%; - margin-left: -30px; - top: -16px; -} - -.sc-multi-select-input .se-select-editor .se-arrow:after { - content: ""; - position: absolute; - left: 20px; - top: 10px; - width: 20px; - height: 20px; - transform: rotate(45deg); - border: 1px solid #8a8a8a; - background: #fff; + margin-left: var(--t-half-spacing); } \ No newline at end of file diff --git a/src/kit/styles/_popup.css b/src/kit/styles/_popup.css new file mode 100644 index 000000000..789010b0d --- /dev/null +++ b/src/kit/styles/_popup.css @@ -0,0 +1,41 @@ +.sc-popup { + position: absolute; + width: 100%; + top: 55px; + background: var(--t-background-color); + border: var(--t-default-border); + border-radius: var(--t-border-radius); + max-width: 100%; + box-shadow: var(--t-popup-box-shadow); + z-index: 10; +} + +.sc-popup .se-popup-label { + text-align: center; + font-size: var(--t-tiny-font-size); + color: var(--t-light-text-color); + margin-top: var(--t-half-spacing); +} + +.sc-popup .se-popup-arrow { + width: 60px; + height: 16px; + overflow: hidden; + position: absolute; + left: 50%; + margin-left: -30px; + top: -16px; +} + +.sc-popup .se-popup-arrow:after { + content: ""; + position: absolute; + left: 20px; + top: 10px; + width: 20px; + height: 20px; + transform: rotate(45deg); + border: var(--t-default-border); + background: var(--t-background-color); + box-shadow: var(--t-popup-box-shadow); +} \ No newline at end of file diff --git a/src/kit/styles/_text-input.css b/src/kit/styles/_text-input.css index 9efd88944..e90946a7f 100644 --- a/src/kit/styles/_text-input.css +++ b/src/kit/styles/_text-input.css @@ -5,7 +5,7 @@ .sc-text-input .se-input { width: 100%; height: var(--t-input-height); - border-radius: 5px; + border-radius: var(--t-border-radius); padding: var(--t-input-padding); border: var(--t-input-default-border); margin: var(--t-negative-input-padding); diff --git a/src/kit/ui/Input.js b/src/kit/ui/Input.js index b3aace7d4..72811bd75 100644 --- a/src/kit/ui/Input.js +++ b/src/kit/ui/Input.js @@ -60,9 +60,11 @@ export default class Input extends Component { switch (combo) { // ESCAPE reverts the current pending change case ESCAPE: { - event.stopPropagation() - event.preventDefault() - this.el.val(this._getDocumentValue()) + if (this.props.handleEscape !== false) { + event.stopPropagation() + event.preventDefault() + this.el.val(this._getDocumentValue()) + } break } default: diff --git a/src/kit/ui/MultiSelectInput.js b/src/kit/ui/MultiSelectInput.js index 0a1424e07..9cae0faee 100644 --- a/src/kit/ui/MultiSelectInput.js +++ b/src/kit/ui/MultiSelectInput.js @@ -1,5 +1,6 @@ import { Component } from 'substance' import OverlayMixin from './OverlayMixin' +import Popup from './Popup' export default class MultiSelectInput extends OverlayMixin(Component) { getInitialState () { @@ -43,11 +44,7 @@ export default class MultiSelectInput extends OverlayMixin(Component) { const selected = this.props.selected const selectedIdx = selected.map(item => item.id) const options = this._getOptions() - const editorEl = $$('div').ref('options').addClass('se-select-editor').append( - $$('div').addClass('se-arrow'), - $$('div').addClass('se-select-label') - .append(label) - ) + const editorEl = $$('div').ref('options').addClass('se-select-editor') options.forEach(option => { const isSelected = selectedIdx.indexOf(option.id) > -1 const icon = isSelected ? 'checked-item' : 'unchecked-item' @@ -60,11 +57,7 @@ export default class MultiSelectInput extends OverlayMixin(Component) { ).on('click', this._onToggleItem.bind(this, option)) ) }) - return editorEl - } - - _getOverlayId () { - return this.props.overlayId || this.getId() + return $$(Popup, { label }).append(editorEl) } _getOptions () { diff --git a/src/kit/ui/OverlayMixin.js b/src/kit/ui/OverlayMixin.js index 5da1e3e24..56e36e0fd 100644 --- a/src/kit/ui/OverlayMixin.js +++ b/src/kit/ui/OverlayMixin.js @@ -1,5 +1,9 @@ export default function (Component) { class OverlayComponent extends Component { + getChildContext () { + return { overlay: this } + } + didMount () { super.didMount() @@ -15,7 +19,7 @@ export default function (Component) { } _getOverlayId () { - return this.getId() + return this.props.overlayId || this.getId() } _canShowOverlay () { diff --git a/src/kit/ui/Popup.js b/src/kit/ui/Popup.js new file mode 100644 index 000000000..bc3c73b09 --- /dev/null +++ b/src/kit/ui/Popup.js @@ -0,0 +1,24 @@ +import { Component } from 'substance' + +export default class Popup extends Component { + render ($$) { + const label = this.props.label + const children = this.props.children + + const el = $$('div').addClass('sc-popup').append( + $$('div').addClass('se-popup-arrow') + ) + + if (label) { + el.append( + $$('div').addClass('se-popup-label').append(label) + ) + } + + el.append( + $$('div').addClass('se-popup-content').append(children) + ) + + return el + } +} diff --git a/src/kit/ui/index.js b/src/kit/ui/index.js index d298e8053..642746f9a 100644 --- a/src/kit/ui/index.js +++ b/src/kit/ui/index.js @@ -30,6 +30,7 @@ export { default as ObjectComponent } from './ObjectComponent' export { default as OverlayCanvas } from './OverlayCanvas' export { default as OverlayMixin } from './OverlayMixin' export { default as PinnedMessage } from './PinnedMessage' +export { default as Popup } from './Popup' export { default as renderModel } from './_renderModel' export { default as renderNode } from './_renderNode' export { default as renderValue } from './_renderValue' diff --git a/src/styles/_texture.css b/src/styles/_texture.css index 4fcd3fa87..e46d85e17 100644 --- a/src/styles/_texture.css +++ b/src/styles/_texture.css @@ -68,6 +68,8 @@ --t-placeholder-text-color: #ccc; /* Used for focus border, e.g. selected card, or text input */ --t-focus-color: rgb(145, 189, 240); + /* Used for focus border, e.g. selected card, or text input */ + --t-focus-background-color: #218df312; /* E.g. citations of references, figures, etc. */ --t-action-color: #2e72ea; /* Used to display warning icons */ @@ -97,7 +99,7 @@ --t-input-default-border: 2px solid transparent; --t-input-outline-border: 2px solid var(--t-border-color); - --t-input-focus-border: 2px solid rgb(145, 189, 240); + --t-input-focus-border: 2px solid var(--t-focus-color); --t-negative-input-padding: -6px; /* This must be the negative of input padding + default border width */ --t-negative-list-padding: -4px; /* Same, but without borders. Used in comma-separated lists. */ --t-border-radius: 5px; /* Default border radius for rounded corners */ @@ -108,7 +110,7 @@ /* ----------------------------------------------------------------------*/ --t-default-box-shadow: 0 0 0 0.75pt #d1d1d1, 0 0 3pt 0.75pt #ccc; - --t-popup-box-shadow: 0 2px 10px -2px rgba(0,0,0,0.8); + --t-popup-box-shadow: 0 2px 4px 0 var(--t-border-color); /* Substance Styles */ /* ----------------------------------------------------------------------*/ diff --git a/test/Figure.test.js b/test/Figure.test.js index 2de8b7333..82d582506 100644 --- a/test/Figure.test.js +++ b/test/Figure.test.js @@ -24,7 +24,8 @@ const xrefListItemSelector = '.sc-edit-xref-tool .se-option .sc-preview' const figurePanelPreviousSelector = '.sc-figure .se-control.sm-previous' const figurePanelNextSelector = '.sc-figure .se-control.sm-next' const currentPanelSelector = '.sc-figure .se-current-panel .sc-figure-panel' -const figureCustomMetadataFieldInputSelector = '.sc-custom-metadata-field .sc-string' +const figureCustomMetadataFieldNameSelector = '.sc-custom-metadata-field .sc-string' +const figureCustomMetadataFieldValuesSelector = '.sc-custom-metadata-field .sc-keyword-input' const FIGURE_WITH_TWO_PANELS = ` @@ -462,9 +463,12 @@ test('Figure: replicate first panel structure', t => { t.ok(insertFigurePanelTool.el.click(), 'clicking on the insert figure panel button should not throw error') insertFigurePanelTool.onFileSelect(new PseudoFileEvent()) _gotoNext() - const fields = editor.findAll(figureCustomMetadataFieldInputSelector) - t.equal(fields[0].getTextContent(), 'Field I', 'shoud be replicated keyword label inside custom field name') - t.equal(fields[1].getTextContent(), '', 'shoud be empty value') + const fieldNames = editor.findAll(figureCustomMetadataFieldNameSelector) + const fieldValues = editor.findAll(figureCustomMetadataFieldValuesSelector) + t.equal(fieldNames.length, 1, 'there should be one input for a field name') + t.equal(fieldNames.length, fieldValues.length, 'there should be the same number of custom metadata field name and values') + t.equal(fieldNames[0].getTextContent(), 'Field I', 'shoud be replicated keyword label inside custom field name') + t.equal(fieldValues[0].getTextContent(), 'Click to add keywords', 'shoud be empty value') t.end() }) diff --git a/test/FigureMetadata.test.js b/test/FigureMetadata.test.js index cf37c0e39..38e82cad9 100644 --- a/test/FigureMetadata.test.js +++ b/test/FigureMetadata.test.js @@ -11,8 +11,8 @@ const moveUpCustomMetadataFieldToolSelector = '.sm-move-up-metadata-field' const removeCustomMetadataFieldToolSelector = '.sm-remove-metadata-field' const figureMetadataSelector = '.sc-custom-metadata-field' -const figureCustomMetadataFieldInputSelector = '.sc-custom-metadata-field .sc-string' -const figureCustomMetadataFieldNameSelector = '.sc-custom-metadata-field .se-field-name .se-input' +const figureCustomMetadataFieldNameSelector = '.sc-custom-metadata-field .sc-string' +const figureCustomMetadataFieldValuesSelector = '.sc-custom-metadata-field .sc-keyword-input' const FIXTURE = ` @@ -35,10 +35,12 @@ test('Figure Metadata: open figure with custom fields in manuscript and metadata let editor = openManuscriptEditor(app) loadBodyFixture(editor, FIXTURE) t.notNil(editor.find(figureMetadataSelector), 'there should be a figure with metadata in manuscript') - const fields = editor.findAll(figureCustomMetadataFieldInputSelector) - t.equal(fields.length, 2, 'there should be two inputs') - t.equal(fields[0].getTextContent(), 'Field I', 'shoud be keyword label inside first') - t.equal(fields[1].getTextContent(), 'Value A, Value B', 'shoud be values joined with comma inside second') + const fieldNames = editor.findAll(figureCustomMetadataFieldNameSelector) + const fieldValues = editor.findAll(figureCustomMetadataFieldValuesSelector) + t.equal(fieldNames.length, 1, 'there should be one input for a field name') + t.equal(fieldNames.length, fieldValues.length, 'there should be the same number of custom metadata field name and values') + t.equal(fieldNames[0].getTextContent(), 'Field I', 'shoud be keyword label inside first') + t.equal(fieldValues[0].getTextContent(), 'Value A, Value B', 'shoud be values joined with comma inside second') editor = openMetadataEditor(app) t.notNil(editor.find(figureMetadataSelector), 'there should be a figure with metadata in manuscript') t.end() @@ -54,8 +56,9 @@ test('Figure Metadata: add a new custom field', t => { t.ok(addCustomMetadataFieldTool.click(), 'clicking on add custom field tool should not throw error') t.equal(editor.findAll(figureMetadataSelector).length, 2, 'there should be two custom fields now') const selectedNodePath = getSelection(editor).path - const secondCustomFieldInputPath = editor.findAll(figureCustomMetadataFieldNameSelector)[1].getPath() - t.deepEqual(selectedNodePath, secondCustomFieldInputPath, 'selection path and second custom field path should match') + const secondCustomFieldInput = editor.findAll(figureCustomMetadataFieldNameSelector)[1] + const secondCustomFieldInputModel = secondCustomFieldInput.props.model + t.deepEqual(selectedNodePath, secondCustomFieldInputModel.getPath(), 'selection path and second custom field path should match') t.end() }) @@ -70,8 +73,9 @@ test('Figure Metadata: add a new custom field when figure is selected', t => { t.ok(addCustomMetadataFieldTool.click(), 'clicking on add custom field tool should not throw error') t.equal(editor.findAll(figureMetadataSelector).length, 2, 'there should be two custom fields now') const selectedNodePath = getSelection(editor).path - const secondCustomFieldInputPath = editor.findAll(figureCustomMetadataFieldNameSelector)[1].getPath() - t.deepEqual(selectedNodePath, secondCustomFieldInputPath, 'selection path and second custom field path should match') + const secondCustomFieldInput = editor.findAll(figureCustomMetadataFieldNameSelector)[1] + const secondCustomFieldInputModel = secondCustomFieldInput.props.model + t.deepEqual(selectedNodePath, secondCustomFieldInputModel.getPath(), 'selection path and second custom field path should match') t.end() })