Repository: Atypon-OpenSource/manuscripts-body-editor
Pull request #555: Unindent sections - LEAN-4357
Author: login: IslamJMomani
Created: 2025-03-10T13:42:44Z | Merged: 2025-03-13T10:46:46Z

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


Diff:
diff --git a/package.json b/package.json
index 3b96f2999..5187d610a 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.7",
+  "version": "2.8.8",
   "repository": "github:Atypon-OpenSource/manuscripts-body-editor",
   "license": "Apache-2.0",
   "main": "dist/cjs",
diff --git a/src/components/toolbar/helpers.ts b/src/components/toolbar/helpers.ts
index fdc1bd9b6..7aee391cd 100644
--- a/src/components/toolbar/helpers.ts
+++ b/src/components/toolbar/helpers.ts
@@ -245,45 +245,50 @@ export const promoteParagraphToSection = (
   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
-  }
+export const isIndentationAllowed =
+  (action: string) => (state: EditorState) => {
+    const { $from } = state.selection
+    const nodeType = $from.node().type
 
-  const isInBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
-  const isDeletedNode = isDeleted($from.node($from.depth))
-  if (!isInBody || isDeletedNode) {
-    return false
-  }
+    const allowedNodeTypes = [
+      schema.nodes.section_title,
+      schema.nodes.paragraph,
+    ]
+    if (!allowedNodeTypes.includes(nodeType)) {
+      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
-    ) {
+    const isInBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
+    const isDeletedNode = isDeleted($from.node($from.depth))
+    if (!isInBody || isDeletedNode) {
       return false
     }
-  }
 
-  return true
-}
+    // It is not allowed to unindent the top level parent sections, headers and paragraphs
+    if (action === 'unindent') {
+      const grandparentNode = $from.node($from.depth - 2)
+      if (grandparentNode?.type === schema.nodes.body) {
+        return false
+      }
+    }
 
-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)
+    if (nodeType === schema.nodes.paragraph) {
+      if (action === 'indent') {
+        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
+        }
+      } else {
+        // TODO:: Allow unindent paragraphs when the implementation ready
+        return false
+      }
     }
+
+    return true
   }
 
 export const indentSection =
@@ -370,3 +375,86 @@ export const indentParagraph =
     dispatch(skipTracking(tr))
     view?.focus()
   }
+
+export const unindentSection =
+  () => (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 sectionTitle = $from.node($from.depth)
+
+    const $beforeSection = tr.doc.resolve(beforeSection)
+    const beforeSectionOffset = $beforeSection.parentOffset
+    const afterSectionOffset = beforeSectionOffset + section.nodeSize
+
+    const parentSectionDepth = $from.depth - 2
+    const parentSection = $from.node(parentSectionDepth)
+    const startIndex = $from.index(parentSectionDepth)
+    const endIndex = $from.indexAfter(parentSectionDepth)
+    const beforeParentSection = $from.before(parentSectionDepth)
+    const afterParentSection = $from.after(parentSectionDepth)
+
+    const items = []
+
+    let offset = 0
+
+    if (startIndex > 0) {
+      const precedingSection = parentSection.cut(0, beforeSectionOffset)
+      items.push(precedingSection)
+      offset += precedingSection.nodeSize
+    }
+
+    items.push(section)
+
+    if (endIndex < parentSection.childCount) {
+      const fragment = Fragment.from(nodes.section_title.create()).append(
+        parentSection.content.cut(afterSectionOffset)
+      )
+
+      items.push(parentSection.copy(fragment))
+    }
+
+    tr.replaceWith(beforeParentSection, afterParentSection, items)
+
+    const anchor = beforeParentSection + offset + 2
+
+    tr.setSelection(
+      TextSelection.create(tr.doc, anchor, anchor + sectionTitle.content.size)
+    )
+
+    dispatch(skipTracking(tr))
+    view && view.focus()
+  }
+
+const unindentParagraph =
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  () => (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
+    /* Placeholder for unindentParagraph */
+  }
+
+const indentationHandlers = {
+  section_title: {
+    indent: indentSection,
+    unindent: unindentSection,
+  },
+  paragraph: {
+    indent: indentParagraph,
+    unindent: unindentParagraph,
+  },
+}
+
+export const changeIndentation =
+  (action: 'indent' | 'unindent') =>
+  (state: EditorState, dispatch: Dispatch, view?: EditorView) => {
+    const { $from } = state.selection
+    const nodeName = $from.node().type.name as 'section_title' | 'paragraph'
+
+    const handler = indentationHandlers[nodeName]?.[action]
+    handler()(state, dispatch, view)
+  }
diff --git a/src/toolbar.tsx b/src/toolbar.tsx
index a35e37abb..700a042a4 100644
--- a/src/toolbar.tsx
+++ b/src/toolbar.tsx
@@ -27,6 +27,7 @@ import {
   ToolbarSuperscriptIcon,
   ToolbarTableIcon,
   ToolbarUnderlineIcon,
+  ToolbarUnindentIcon,
   ToolbarUnorderedListIcon,
 } from '@manuscripts/style-guide'
 import { schema } from '@manuscripts/transform'
@@ -45,7 +46,10 @@ import {
   insertList,
   markActive,
 } from './commands'
-import { canIndent, indent } from './components/toolbar/helpers'
+import {
+  changeIndentation,
+  isIndentationAllowed,
+} from './components/toolbar/helpers'
 import { openInsertTableDialog } from './components/toolbar/InsertTableDialog'
 
 export interface ToolbarButtonConfig {
@@ -74,8 +78,14 @@ export const toolbar: ToolbarConfig = {
     indent: {
       title: 'Indent',
       content: <ToolbarIndentIcon />,
-      isEnabled: canIndent(),
-      run: indent(),
+      isEnabled: isIndentationAllowed('indent'),
+      run: changeIndentation('indent'),
+    },
+    unindent: {
+      title: 'Unindent',
+      content: <ToolbarUnindentIcon />,
+      isEnabled: isIndentationAllowed('unindent'),
+      run: changeIndentation('unindent'),
     },
   },
   style: {


Reviews:
user: login: nghazzawi-atypon
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T23:30:52Z
user: login: IslamJMomani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T10:22:18Z
user: login: IslamJMomani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T10:25:45Z
user: login: nghazzawi-atypon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T10:57:46Z
user: login: mbartenev-atypon
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T17:06:28Z

Review comments:
diff_hunk: @@ -245,35 +245,51 @@ export const promoteParagraphToSection = (
   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
-  }
+export const isIndentationAllowed =
+  (action: string) => (state: EditorState) => {
+    const { $from } = state.selection
+    const nodeType = $from.node().type
 
-  const isInBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
-  const isDeletedNode = isDeleted($from.node($from.depth))
-  if (!isInBody || isDeletedNode) {
-    return false
-  }
+    const allowedNodeTypes = [
path: src/components/toolbar/helpers.ts
user: login: nghazzawi-atypon
body: can we change this to : 

```
    ```
const allowedNodeTypes =
      action === 'indent'
        ? [schema.nodes.section_title, schema.nodes.paragraph] // Allowed nodes for indent
        : [schema.nodes.section_title] // Allowed nodes for unindent

```
and Remove the condition once unindent paragraph is merged to avoid issues during QA testing
created_at: 2025-03-10T23:02:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,6 +302,15 @@ export const indent =
     }
   }
 
+export const unindent =
path: src/components/toolbar/helpers.ts
user: login: nghazzawi-atypon
body: I think we can combine indent and unindent into a single function based on the passed action. Please take a look at what I did here. : https://github.com/Atypon-OpenSource/manuscripts-body-editor/pull/556
created_at: 2025-03-10T23:29:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,6 +302,15 @@ export const indent =
     }
   }
 
+export const unindent =
path: src/components/toolbar/helpers.ts
user: login: IslamJMomani
body: I think combining them is a good idea, so I did combine them, but in a different way than in your PR, for better readability and scalability (to easily support more node types).
created_at: 2025-03-11T10:22:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -245,35 +245,51 @@ export const promoteParagraphToSection = (
   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
-  }
+export const isIndentationAllowed =
+  (action: string) => (state: EditorState) => {
+    const { $from } = state.selection
+    const nodeType = $from.node().type
 
-  const isInBody = hasParentNodeOfType(schema.nodes.body)(state.selection)
-  const isDeletedNode = isDeleted($from.node($from.depth))
-  if (!isInBody || isDeletedNode) {
-    return false
-  }
+    const allowedNodeTypes = [
path: src/components/toolbar/helpers.ts
user: login: IslamJMomani
body: I've already taken that into account and disabled the button for paragraph unindent. Please check line 278. That should also be removed once we have your code.
created_at: 2025-03-11T10:25:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: zax-assistant[bot]
created_at: 2025-03-10T13:51:57Z
body: Published version `2.8.8-LEAN-4357.0`
author_association: CONTRIBUTOR
user: login: zax-assistant[bot]
created_at: 2025-03-11T10:31:07Z
body: Published version `2.8.8-LEAN-4357.1`
author_association: CONTRIBUTOR