Repository: Atypon-OpenSource/manuscripts-body-editor
Pull request #552: Indent sections - LEAN-4356
Author: login: IslamJMomani
Created: 2025-03-02T13:02:38Z | Merged: 2025-03-09T11:19:13Z

https://www.figma.com/design/V92JzC7qFbCibqoK8zub0u/Section-type-drop-down-design?node-id=27306-92910&m=dev


Diff:
diff --git a/package.json b/package.json
index 58686f512..a8f1b7af3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@manuscripts/body-editor",
   "description": "Prosemirror components for editing and viewing manuscripts",
-  "version": "2.8.5",
+  "version": "2.8.6",
   "repository": "github:Atypon-OpenSource/manuscripts-body-editor",
   "license": "Apache-2.0",
   "main": "dist/cjs",
@@ -32,7 +32,7 @@
     "@iarna/word-count": "^1.1.2",
     "@manuscripts/json-schema": "2.2.11",
     "@manuscripts/library": "1.3.11",
-    "@manuscripts/style-guide": "2.1.3",
+    "@manuscripts/style-guide": "2.1.4",
     "@manuscripts/track-changes-plugin": "1.10.0",
     "@manuscripts/transform": "3.0.45",
     "@popperjs/core": "^2.11.8",
diff --git a/src/commands.ts b/src/commands.ts
index 08cb80a09..374e5610c 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -42,7 +42,6 @@ import {
   schema,
   SectionCategory,
   SectionNode,
-  SectionTitleNode,
   SupplementNode,
 } from '@manuscripts/transform'
 import {
@@ -1744,86 +1743,6 @@ export const autoComplete = (
   return false
 }
 
-export const canIndent = (state: ManuscriptEditorState) => {
-  const { $from } = state.selection
-  const node = $from.node($from.depth)
-
-  // Allow paragraphs
-  const allowedNodeTypes = [schema.nodes.paragraph]
-  if (!allowedNodeTypes.includes(node.type)) {
-    return false
-  }
-
-  // Prevent indentation for frontmatter and backmatter sections
-  const isBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
-  if (!isBody || isDeleted(node)) {
-    return false
-  }
-
-  if (node.type === schema.nodes.paragraph) {
-    const parentNode = $from.node($from.depth - 1)
-    // Allow indentation if the parent is a section or body (e.g., for orphan paragraphs like empty submissions)
-    if (
-      parentNode?.type !== schema.nodes.section &&
-      parentNode?.type !== schema.nodes.body
-    ) {
-      return false
-    }
-  }
-
-  return true
-}
-
-export const indent =
-  () => (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
-    const { $from } = state.selection
-    const nodeType = $from.node().type
-    if (nodeType === schema.nodes.paragraph) {
-      indentParagraph()(state, dispatch, view)
-    }
-  }
-
-export const indentParagraph =
-  () =>
-  (
-    state: ManuscriptEditorState,
-    dispatch?: Dispatch,
-    view?: ManuscriptEditorView
-  ) => {
-    const { $from } = state.selection
-    const { schema, tr } = state
-
-    const beforeParagraph = $from.before($from.depth)
-    const sectionDepth = $from.depth - 1
-    const parentSection = $from.node(sectionDepth)
-    const sectionStart = $from.start(sectionDepth)
-    const sectionEnd = $from.end(sectionDepth)
-
-    const sectionTitle: SectionTitleNode = schema.nodes.section_title.create()
-
-    // Build section content
-    const sectionContent = Fragment.from(sectionTitle).append(
-      parentSection.content.cut(beforeParagraph - sectionStart)
-    )
-
-    // Create new section
-    const newSection = schema.nodes.section.create(
-      { id: generateNodeID(schema.nodes.section) },
-      sectionContent
-    )
-
-    // Replace original paragraph and moved nodes with the new section
-    tr.replaceWith(beforeParagraph, sectionEnd, newSection)
-
-    // Set selection inside the title of the new section
-    tr.setSelection(TextSelection.create(tr.doc, beforeParagraph + 2))
-
-    if (dispatch) {
-      dispatch(skipTracking(tr))
-      view?.focus()
-    }
-  }
-
 export const activateSearch = (
   state: ManuscriptEditorState,
   dispatch?: Dispatch
diff --git a/src/components/toolbar/helpers.ts b/src/components/toolbar/helpers.ts
index 1b5947567..fdc1bd9b6 100644
--- a/src/components/toolbar/helpers.ts
+++ b/src/components/toolbar/helpers.ts
@@ -20,11 +20,16 @@ import {
   ManuscriptEditorView,
   ManuscriptNodeType,
   nodeNames,
+  schema,
   SectionTitleNode,
 } from '@manuscripts/transform'
 import { Fragment, Node } from 'prosemirror-model'
 import { EditorState, TextSelection, Transaction } from 'prosemirror-state'
+import { hasParentNodeOfType } from 'prosemirror-utils'
+import { EditorView } from 'prosemirror-view'
 
+import { Dispatch } from '../../commands'
+import { isDeleted } from '../../lib/track-changes-utils'
 import { Option } from './type-selector/TypeSelector'
 
 export const optionName = (nodeType: ManuscriptNodeType) => {
@@ -239,3 +244,129 @@ export const promoteParagraphToSection = (
   dispatch(skipTracking(tr))
   view && view.focus()
 }
+
+export const canIndent = () => (state: EditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+
+  const allowedNodeTypes = [schema.nodes.section_title, schema.nodes.paragraph]
+  if (!allowedNodeTypes.includes(nodeType)) {
+    return false
+  }
+
+  const isInBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+  const isDeletedNode = isDeleted($from.node($from.depth))
+  if (!isInBody || isDeletedNode) {
+    return false
+  }
+
+  const isParagraph = nodeType === schema.nodes.paragraph
+  if (isParagraph) {
+    const parentNode = $from.node($from.depth - 1)
+    // Allow indentation if the parent is a section or body (e.g., for orphan paragraphs like empty submissions)
+    if (
+      parentNode?.type !== schema.nodes.section &&
+      parentNode?.type !== schema.nodes.body
+    ) {
+      return false
+    }
+  }
+
+  return true
+}
+
+export const indent =
+  () => (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
+    const { $from } = state.selection
+    const nodeType = $from.node().type
+    if (nodeType === schema.nodes.section_title) {
+      indentSection()(state, dispatch, view)
+    } else if (nodeType === schema.nodes.paragraph) {
+      indentParagraph()(state, dispatch, view)
+    }
+  }
+
+export const indentSection =
+  () => (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
+    const {
+      selection: { $from },
+      schema,
+      tr,
+    } = state
+    const { nodes } = schema
+
+    const sectionDepth = $from.depth - 1
+    const section = $from.node(sectionDepth)
+    const beforeSection = $from.before(sectionDepth)
+    const afterSection = $from.after(sectionDepth)
+
+    const parentSectionDepth = sectionDepth - 1
+    const parentSection = $from.node(parentSectionDepth)
+    const startIndex = $from.index(parentSectionDepth)
+
+    const previousSection =
+      startIndex > 0 ? parentSection.child(startIndex - 1) : null
+    const isValidContainer = previousSection?.type === nodes.section
+
+    let anchor
+    if (!previousSection || !isValidContainer) {
+      // No valid previous section, creating new parent section
+      const emptyTitle = nodes.section_title.create()
+      const newParentSectionContent = Fragment.fromArray([emptyTitle, section])
+      const newParentSection = nodes.section.create({}, newParentSectionContent)
+
+      tr.replaceWith(beforeSection, afterSection, newParentSection)
+
+      anchor = beforeSection + 1
+    } else {
+      // Moving section into previous section as subsection
+      const newPreviousSection = previousSection.copy(
+        previousSection.content.append(Fragment.from(section))
+      )
+
+      const beforePreviousSection = beforeSection - previousSection.nodeSize
+
+      tr.replaceWith(beforePreviousSection, afterSection, newPreviousSection)
+
+      anchor = beforePreviousSection + 1
+    }
+
+    tr.setSelection(TextSelection.create(tr.doc, anchor))
+
+    dispatch(skipTracking(tr))
+    view && view.focus()
+  }
+
+export const indentParagraph =
+  () => (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
+    const { $from } = state.selection
+    const { schema, tr } = state
+
+    const beforeParagraph = $from.before($from.depth)
+    const sectionDepth = $from.depth - 1
+    const parentSection = $from.node(sectionDepth)
+    const sectionStart = $from.start(sectionDepth)
+    const sectionEnd = $from.end(sectionDepth)
+
+    const sectionTitle: SectionTitleNode = schema.nodes.section_title.create()
+
+    // Build section content
+    const sectionContent = Fragment.from(sectionTitle).append(
+      parentSection.content.cut(beforeParagraph - sectionStart)
+    )
+
+    // Create new section
+    const newSection = schema.nodes.section.create(
+      { id: generateNodeID(schema.nodes.section) },
+      sectionContent
+    )
+
+    // Replace original paragraph and moved nodes with the new section
+    tr.replaceWith(beforeParagraph, sectionEnd, newSection)
+
+    // Set selection inside the title of the new section
+    tr.setSelection(TextSelection.create(tr.doc, beforeParagraph + 2))
+
+    dispatch(skipTracking(tr))
+    view?.focus()
+  }
diff --git a/src/toolbar.tsx b/src/toolbar.tsx
index 9bddce34d..a35e37abb 100644
--- a/src/toolbar.tsx
+++ b/src/toolbar.tsx
@@ -38,15 +38,14 @@ import React, { ReactNode } from 'react'
 import {
   addInlineComment,
   blockActive,
-  canIndent,
   canInsert,
   Dispatch,
-  indent,
   insertBlock,
   insertInlineCitation,
   insertList,
   markActive,
 } from './commands'
+import { canIndent, indent } from './components/toolbar/helpers'
 import { openInsertTableDialog } from './components/toolbar/InsertTableDialog'
 
 export interface ToolbarButtonConfig {
@@ -75,7 +74,7 @@ export const toolbar: ToolbarConfig = {
     indent: {
       title: 'Indent',
       content: <ToolbarIndentIcon />,
-      isEnabled: (state) => canIndent(state),
+      isEnabled: canIndent(),
       run: indent(),
     },
   },
diff --git a/yarn.lock b/yarn.lock
index f79155867..2e105e0ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1574,10 +1574,10 @@
     "@manuscripts/json-schema" "2.2.10"
     citeproc "^2.4.62"
 
-"@manuscripts/style-guide@2.1.3":
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/@manuscripts/style-guide/-/style-guide-2.1.3.tgz#09b90bffefe051ded23945fa3bab67bfe5dbf9d3"
-  integrity sha512-ns4+Sk6YARMdyWTS3y3dKxsoZHmUUo+E/0o0yYLD3yF05LMyeoAUIo+N1Y1NCwzT6fl33aCRuQYrwfibEqiBOw==
+"@manuscripts/style-guide@2.1.4":
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@manuscripts/style-guide/-/style-guide-2.1.4.tgz#7046fbb1aa68c9b609364810df39c99ac8b06f7a"
+  integrity sha512-00kJiqMy/D/eApo581Q8oQuGZ4bwc9VfTGwB94HQnhEs/LxNYz+nVJuSgMPJCOjvx6Q2SDbfO0FPYIEELVg6Kw==
   dependencies:
     "@headlessui/react" "^2.1.10"
     "@manuscripts/json-schema" "2.2.11"


Reviews:
user: login: asouqi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T00:15:12Z
user: login: IslamJMomani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T09:23:57Z
user: login: asouqi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T09:29:12Z
user: login: IslamJMomani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:08:25Z

Review comments:
diff_hunk: @@ -1748,3 +1748,82 @@ export const autoComplete = (
   }
   return false
 }
+export const canIndent = () => (state: ManuscriptEditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+
+  // We should also add paragraph once the implementation done
+  const allowedNodeTypes = [schema.nodes.section_title]
+  if (!allowedNodeTypes.includes(nodeType)) {
+    return false
+  }
+
+  // Check if the selection is inside the body
+  const isBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+  if (!isBody) {
+    return false
+  }
+
+  return true
+}
+
+export const indent = () => (state: EditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+  if (nodeType === schema.nodes.section_title) {
+    moveSectionToSubsection()
path: src/commands.ts
user: login: asouqi
body: we need to call the second function, or keep it as one function
created_at: 2025-03-03T00:15:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1748,3 +1748,82 @@ export const autoComplete = (
   }
   return false
 }
+export const canIndent = () => (state: ManuscriptEditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+
+  // We should also add paragraph once the implementation done
+  const allowedNodeTypes = [schema.nodes.section_title]
+  if (!allowedNodeTypes.includes(nodeType)) {
+    return false
+  }
+
+  // Check if the selection is inside the body
+  const isBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+  if (!isBody) {
+    return false
+  }
+
+  return true
+}
+
+export const indent = () => (state: EditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+  if (nodeType === schema.nodes.section_title) {
+    moveSectionToSubsection()
path: src/commands.ts
user: login: IslamJMomani
body: Yes, we will call the other function when node type is paragraph once we add paragraph to the `allowedNodeTypes`
created_at: 2025-03-03T09:23:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1748,3 +1748,82 @@ export const autoComplete = (
   }
   return false
 }
+export const canIndent = () => (state: ManuscriptEditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+
+  // We should also add paragraph once the implementation done
+  const allowedNodeTypes = [schema.nodes.section_title]
+  if (!allowedNodeTypes.includes(nodeType)) {
+    return false
+  }
+
+  // Check if the selection is inside the body
+  const isBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+  if (!isBody) {
+    return false
+  }
+
+  return true
+}
+
+export const indent = () => (state: EditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+  if (nodeType === schema.nodes.section_title) {
+    moveSectionToSubsection()
path: src/commands.ts
user: login: asouqi
body: so the indent button will not have any effect! `moveSectionToSubsection()` returns just a function and will not execute it's implementation!
created_at: 2025-03-03T09:29:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1748,3 +1748,82 @@ export const autoComplete = (
   }
   return false
 }
+export const canIndent = () => (state: ManuscriptEditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+
+  // We should also add paragraph once the implementation done
+  const allowedNodeTypes = [schema.nodes.section_title]
+  if (!allowedNodeTypes.includes(nodeType)) {
+    return false
+  }
+
+  // Check if the selection is inside the body
+  const isBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+  if (!isBody) {
+    return false
+  }
+
+  return true
+}
+
+export const indent = () => (state: EditorState) => {
+  const { $from } = state.selection
+  const nodeType = $from.node().type
+  if (nodeType === schema.nodes.section_title) {
+    moveSectionToSubsection()
path: src/commands.ts
user: login: IslamJMomani
body: Oh 😮,  it seems I missed that in my last refinement. Thank you for catching it!
created_at: 2025-03-03T10:08:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: zax-assistant[bot]
created_at: 2025-03-02T13:41:37Z
body: Published version `2.7.58-LEAN-4356.0`
author_association: CONTRIBUTOR
user: login: zax-assistant[bot]
created_at: 2025-03-03T12:07:39Z
body: Published version `2.8.1-LEAN-4356.0`
author_association: CONTRIBUTOR