Commit 332601d3 authored by Mikhail Bartenev's avatar Mikhail Bartenev Committed by Casey Ydenberg
Browse files

Footnotes

parent 3d7a54ce
......@@ -17,10 +17,15 @@
import {
BibliographySectionNode,
buildCitation,
buildFootnote,
buildHighlight,
buildInlineMathFragment,
FootnoteNode,
FootnotesElementNode,
FootnotesSectionNode,
generateID,
InlineFootnoteNode,
isElementNodeType,
isFootnotesElementNode,
isSectionNodeType,
ManuscriptEditorState,
ManuscriptEditorView,
......@@ -28,16 +33,19 @@ import {
ManuscriptNode,
ManuscriptNodeSelection,
ManuscriptNodeType,
ManuscriptResolvedPos,
ManuscriptTextSelection,
ManuscriptTransaction,
SectionNode,
TOCSectionNode,
} from '@manuscripts/manuscript-transform'
import { ObjectTypes } from '@manuscripts/manuscripts-json-schema'
import { ResolvedPos } from 'prosemirror-model'
import { NodeSelection, Selection, TextSelection } from 'prosemirror-state'
import { getChildOfType } from './lib/utils'
import { bibliographyKey } from './plugins/bibliography'
import { footnotesKey } from './plugins/footnotes'
import {
getHighlights,
highlightKey,
......@@ -314,25 +322,74 @@ export const insertInlineEquation = (
return true
}
export const insertInlineFootnote = (
interface NodePos {
node: FootnotesElementNode
pos: number
}
const findFootnotesElement = (doc: ManuscriptNode): NodePos | undefined => {
let nodePos: NodePos | undefined = undefined
doc.descendants((node, pos) => {
if (isFootnotesElementNode(node)) {
nodePos = { node, pos }
}
})
return nodePos
}
export const insertInlineFootnote = (kind: 'footnote' | 'endnote') => (
state: ManuscriptEditorState,
dispatch?: Dispatch
) => {
const footnote = buildFootnote(
state.selection.$anchor.parent.attrs.id,
(window.getSelection() || '').toString()
)
const footnote = state.schema.nodes.footnote.createAndFill({
id: generateID(ObjectTypes.Footnote),
kind,
}) as FootnoteNode
const node = state.schema.nodes.inline_footnote.create()
const pos = state.selection.to
const inlineFootnote = state.schema.nodes.inline_footnote.create({
rid: footnote.attrs.id,
}) as InlineFootnoteNode
const tr = state.tr
.setMeta(modelsKey, { [INSERT]: [footnote] })
.insert(pos, node)
// insert the inline footnote, referencing the footnote in the footnotes element in the footnotes section
tr.insert(state.selection.to, inlineFootnote)
const footnotesElementAndPos = findFootnotesElement(tr.doc)
let selectionPos: number
if (footnotesElementAndPos === undefined) {
// create a new footnotes section if needed
const footnotesSection = state.schema.nodes.footnotes_section.create({}, [
state.schema.nodes.section_title.create({}, state.schema.text('Notes')),
state.schema.nodes.footnotes_element.create(
{},
footnote
) as FootnotesElementNode,
]) as FootnotesSectionNode
const insideEndPos = tr.doc.content.size
// TODO: insert bibliography section before footnotes section
tr.insert(insideEndPos, footnotesSection)
selectionPos = insideEndPos - 3 // inside footnote inside element inside section
} else {
const insideEndPos =
footnotesElementAndPos.pos + footnotesElementAndPos.node.nodeSize - 1
// insert footnote at end of last element in section
tr.insert(insideEndPos, footnote)
selectionPos = insideEndPos - 1
}
if (dispatch) {
const selection = NodeSelection.create(tr.doc, pos)
// set selection inside new footnote
const selection = TextSelection.create(tr.doc, selectionPos)
dispatch(tr.setSelection(selection).scrollIntoView())
}
......@@ -367,14 +424,14 @@ export const insertKeywordsSection = (
return true
}
const findPosAfterParentSection = (state: ManuscriptEditorState) => {
const { $from } = state.selection
for (let d = $from.depth; d >= 0; d--) {
const node = $from.node(d)
const findPosAfterParentSection = (
$pos: ManuscriptResolvedPos
): number | null => {
for (let d = $pos.depth; d >= 0; d--) {
const node = $pos.node(d)
if (isSectionNodeType(node.type)) {
return $from.after(d)
return $pos.after(d)
}
}
......@@ -385,7 +442,7 @@ export const insertSection = (subsection = false) => (
state: ManuscriptEditorState,
dispatch?: Dispatch
) => {
const pos = findPosAfterParentSection(state)
const pos = findPosAfterParentSection(state.selection.$from)
if (pos === null) {
return false
......@@ -407,6 +464,35 @@ export const insertSection = (subsection = false) => (
return true
}
export const insertFootnotesSection = (
state: ManuscriptEditorState,
dispatch?: Dispatch
) => {
if (getChildOfType(state.doc, state.schema.nodes.footnotes_section)) {
return false
}
const section = state.schema.nodes.footnotes_section.createAndFill({}, [
state.schema.nodes.section_title.create({}, state.schema.text('Notes')),
state.schema.nodes.footnotes_element.create(),
]) as BibliographySectionNode
const pos = state.tr.doc.content.size
const tr = state.tr
tr.insert(pos, section).setMeta(footnotesKey, {
footnotesElementInserted: true,
})
if (dispatch) {
const selection = NodeSelection.create(tr.doc, pos)
dispatch(tr.setSelection(selection).scrollIntoView())
}
return true
}
export const insertBibliographySection = (
state: ManuscriptEditorState,
dispatch?: Dispatch
......
......@@ -77,19 +77,22 @@ export const ApplicationMenus: React.FC<Props> = ({
{menu.enable && menu.isOpen && (
<MenuList>
{menu.submenu &&
menu.submenu.map((submenu, menuIndex) => (
<MenuItemContainer
key={`menu-${menuIndex}`}
item={submenu}
depth={1}
handleMenuItemClick={() =>
handleItemClick([index, menuIndex])
}
handleSubmenuItemClick={(submenuIndex: number) =>
handleItemClick([index, menuIndex, submenuIndex])
}
/>
))}
menu.submenu.map((submenu, menuIndex) => {
// temporarily adding cofigurable footnotes to test them safely
return (
<MenuItemContainer
key={`menu-${menuIndex}`}
item={submenu}
depth={1}
handleMenuItemClick={() =>
handleItemClick([index, menuIndex])
}
handleSubmenuItemClick={(submenuIndex: number) =>
handleItemClick([index, menuIndex, submenuIndex])
}
/>
)
})}
</MenuList>
)}
</MenuContainer>
......
......@@ -103,7 +103,8 @@ export interface ToolbarConfig<S extends Schema> {
export const ManuscriptToolbar: React.FunctionComponent<{
view?: ManuscriptEditorView
}> = ({ view }) => {
footnotesEnabled?: boolean
}> = ({ view, footnotesEnabled }) => {
return (
<ToolbarContainer>
{view && (
......@@ -114,25 +115,29 @@ export const ManuscriptToolbar: React.FunctionComponent<{
{Object.entries(toolbar).map(([groupKey, toolbarGroup]) => (
<ToolbarGroup key={groupKey}>
{Object.entries(toolbarGroup).map(([itemKey, item]) => (
<ToolbarItem key={itemKey}>
<ToolbarButton
title={item.title}
data-active={view && item.active && item.active(view.state)}
disabled={!view || (item.enable && !item.enable(view.state))}
onMouseDown={(event) => {
event.preventDefault()
if (!view) {
return
}
item.run(view.state, view.dispatch)
view.focus()
}}
>
{item.content}
</ToolbarButton>
</ToolbarItem>
))}
{Object.entries(toolbarGroup)
.filter(
([itemKey]) => !(itemKey === 'footnotes' && !footnotesEnabled) // Excluding 'Add Footnote' menu if footnotes are disabled in the config
) // footnote check is temporal change. Footnotes is supposed to be a non-optional feature once fully ready for production
.map(([itemKey, item]) => (
<ToolbarItem key={itemKey}>
<ToolbarButton
title={item.title}
data-active={view && item.active && item.active(view.state)}
disabled={!view || (item.enable && !item.enable(view.state))}
onMouseDown={(event) => {
event.preventDefault()
if (!view) {
return
}
item.run(view.state, view.dispatch)
view.focus()
}}
>
{item.content}
</ToolbarButton>
</ToolbarItem>
))}
</ToolbarGroup>
))}
</ToolbarContainer>
......
......@@ -34,8 +34,10 @@ import {
insertBibliographySection,
insertBlock,
insertCrossReference,
// insertFootnotesSection, // this is disabled by commenting until we test the footnotes
insertInlineCitation,
insertInlineEquation,
insertInlineFootnote,
insertKeywordsSection,
insertLink,
insertSection,
......@@ -49,12 +51,15 @@ import {
} from './lib/hierarchy'
import { Command, EditorHookValue } from './useEditor'
export default (editor: EditorHookValue<ManuscriptSchema>): MenuSpec[] => {
export default (
editor: EditorHookValue<ManuscriptSchema>,
footnotesEnabled?: boolean
): MenuSpec[] => {
const { isCommandValid, state } = editor
const wrap = (command: Command<ManuscriptSchema>) => () =>
editor.doCommand(command)
return [
const menus = [
{
id: 'edit',
label: 'Edit',
......@@ -268,15 +273,26 @@ export default (editor: EditorHookValue<ManuscriptSchema>): MenuSpec[] => {
enable: isCommandValid(canInsert(schema.nodes.cross_reference)),
run: wrap(insertCrossReference),
},
{
id: 'insert-footnote',
label: 'Footnote',
accelerator: {
mac: 'Option+CommandOrControl+F',
pc: 'CommandOrControl+Option+F',
},
enable: isCommandValid(canInsert(schema.nodes.inline_footnote)),
run: wrap(insertInlineFootnote('footnote')),
},
// endnote type is not used at the moment, this will be needed when we enable them
// {
// id: 'insert-footnote',
// label: 'Footnote',
// id: 'insert-endnote',
// label: () => 'Endnote',
// accelerator: {
// mac: 'Option+CommandOrControl+F',
// pc: 'CommandOrControl+Option+F',
// mac: 'Option+CommandOrControl+E',
// pc: 'CommandOrControl+Option+E',
// },
// enable: canInsert(schema.nodes.inline_footnote),
// run: insertInlineFootnote,
// run: insertInlineFootnote('endnote'),
// },
{
role: 'separator',
......@@ -426,4 +442,14 @@ export default (editor: EditorHookValue<ManuscriptSchema>): MenuSpec[] => {
],
},
]
// this is temporal. once footnotes are production ready this filtering will be removed and the function should return the menus array above
return menus.map((menuGroup: MenuSpec) => {
if (menuGroup.submenu) {
menuGroup.submenu = menuGroup.submenu.filter(
(submenu) => !(submenu.id == 'insert-footnote' && !footnotesEnabled)
)
}
return menuGroup
})
}
......@@ -35,6 +35,7 @@ const icons: Map<
React.FunctionComponent<React.SVGAttributes<SVGElement>>
> = new Map([
[nodes.bibliography_section, SectionIcon],
// TODO: footnotes_section
// [nodes.blockquote_element, BlockQuoteIcon],
[nodes.bullet_list, BulletListIcon],
[nodes.equation_element, EquationIcon],
......
......@@ -34,6 +34,7 @@ import keys from '../keys'
import rules from '../rules'
import bibliography from './bibliography'
import elements from './elements'
import footnotes from './footnotes'
import highlights from './highlight'
import keywords from './keywords'
import models from './models'
......@@ -83,6 +84,7 @@ export default (props: PluginProps) => {
persist(),
sections(),
toc({ modelMap }),
footnotes(props),
styles({ getModel, getManuscript, modelMap }),
keywords({ getManuscript, getModel }),
bibliography({
......
......@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { ManuscriptSchema } from '@manuscripts/manuscript-transform'
import {
isSectionNodeType,
ManuscriptSchema,
} from '@manuscripts/manuscript-transform'
import { Plugin } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
......@@ -34,7 +37,7 @@ export default () => {
// TODO: only calculate these when something changes
state.doc.descendants((node, pos, parent) => {
if (parent.type === section && node.type !== section) {
if (isSectionNodeType(parent.type) && node.type !== section) {
decorations.push(
Decoration.node(
pos,
......
/*!
* © 2019 Atypon Systems LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
InlineFootnoteNode,
isInlineFootnoteNode,
ManuscriptNode,
ManuscriptSchema,
} from '@manuscripts/manuscript-transform'
import { Footnote, Model } from '@manuscripts/manuscripts-json-schema'
import { isEqual } from 'lodash-es'
import { NodeSelection, Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
type GetModel = <T extends Model>(id: string) => T | undefined
type InlineFootnoteNodes = Array<[InlineFootnoteNode, number, Footnote]>
interface PluginState {
nodes: InlineFootnoteNodes
labels: Map<string, string>
}
export const footnotesKey = new PluginKey<PluginState, ManuscriptSchema>(
'footnotes'
)
export const buildPluginState = (
doc: ManuscriptNode,
getModel: GetModel
): PluginState => {
const nodes: InlineFootnoteNodes = []
let index = 0
const labels = new Map<string, string>()
doc.descendants((node, pos) => {
if (isInlineFootnoteNode(node)) {
const footnote = getModel<Footnote>(node.attrs.rid)
if (footnote) {
nodes.push([node, pos, footnote])
labels.set(node.attrs.rid, String(++index))
}
}
})
return { nodes, labels }
}
const scrollToInlineFootnote = (rid: string, view: EditorView) => {
view.state.doc.descendants((node, pos) => {
if (node.attrs.rid === rid) {
const selection = NodeSelection.create(view.state.doc, pos)
view.dispatch(view.state.tr.setSelection(selection).scrollIntoView())
}
})
}
const labelWidget = (label: string, id: string) => (
view: EditorView
): Element => {
const element = document.createElement('span')
element.className = 'footnote-label'
element.textContent = label
element.addEventListener('click', () => {
scrollToInlineFootnote(id, view)
})
return element
}
interface Props {
modelMap: Map<string, Model>
getModel: <T extends Model>(id: string) => T | undefined
}
/**
* This plugin provides support of footnotes related behaviours:
* - It adds and updates superscripted numbering of the footnotes on editor state changes
* - deletes inline footnotes when a footnotes is deleted
* - provides an ability to scroll to a footnote upon clicking on the respective inline footnotes
* TODO:
* 1. use setMeta to notify of updates when the doc hasn't changed in appendTransaction
* if (!transactions.some(transaction => transaction.docChanged)) {
* return null
* }
* 2. re-order footnotes as inline_footnotes are re-ordered? - may cause syncing issues
*
* NOTE:
* 1. The footnotes deletions isn't prevented on purpose as it may cause a syncing issues.
* This, however, maybe required in the future. It maybe done with something like that:
* filterTransaction(transaction, state) {
* const pluginState = footnotesKey.getState(state)
* if (pluginState) {
* const presentFootnotesIds = []
* transaction.doc.descendants((node, pos) => {
* if (isFootnoteNode(node)) {
* presentFootnotesIds.push(node.attrs.id)
* }
* })
* if (presentFootnotesIds.length < pluginState.nodes.length) {
* return false
* }
* }
* return true
* },
*
*/
export default (props: Props) => {
return new Plugin<PluginState, ManuscriptSchema>({
key: footnotesKey,
state: {
init(config, instance): PluginState {
return buildPluginState(instance.doc, props.getModel)
},
apply(tr, value, oldState, newState): PluginState {
return buildPluginState(newState.doc, props.getModel)
},
},
appendTransaction(transactions, oldState, newState) {
const { nodes: oldInlineFootnoteNodes } = footnotesKey.getState(
oldState
) as PluginState
const { nodes: inlineFootnoteNodes, labels } = footnotesKey.getState(
newState
) as PluginState
if (isEqual(inlineFootnoteNodes, oldInlineFootnoteNodes)) {
return null
}
const { tr } = newState
const redundantFootnotesIds = oldInlineFootnoteNodes.reduce(
(acc, [node]) => {
const isRedundant = !inlineFootnoteNodes.some(
([old]) => old.attrs.rid === node.attrs.rid
)