Repository: nhn/tui.editor
Pull request #2294 (2022-02-08 08:20:46)

feat: only enable `indent`, `outdent` toolbar item in the list
<!-- EDIT TITLE PLEASE -->
<!-- It should be one of them
  <ISSUE TYPE>: Short Description (<CLOSING TYPE> #<ISSUE NUMBERS>)
  ex)
  feat: add new feature (close #111)
  fix: wrong behavior (fix #111)
  chore: change build tool (ref #111)
-->

<!-- SPECIFY A ISSUE TYPE AT HEAD
  feat: A new feature
  fix: A bug fix
  docs: Documentation only changes
  style: Changes that do not affect the meaning of the code (white-space, formatting etc)
  refactor: A code change that neither fixes a bug or adds a feature
  perf: A code change that improves performance
  test: Adding missing tests
  chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->

<!-- ADD CLOSING TYPE AND ISSUE NUMBER AT TAIL
  (<CLOSING TYPE> #<ISSUE NUMBERS>)
  close: resolve not a bug(feature, docs, etc) completely
  fix: resolve a bug completely
  ref: not fully resolved or related to
-->

### Please check if the PR fulfills these requirements
- [x] It's the right issue type on the title
- [ ] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] The commit message follows our guidelines
- [ ] Tests for the changes have been added (for bug fixes/features)
- [ ] Docs have been added/updated (for bug fixes/features)
- [ ] It does not introduce a breaking change or has a description of the breaking change

### Description
* related issue(#1990)


---
Thank you for your contribution to TOAST UI product. 🎉 😘 ✨



Patch:
diff --git a/apps/editor/src/markdown/plugins/previewHighlight.ts b/apps/editor/src/markdown/plugins/previewHighlight.ts
index c882dcd8..6dde4ad8 100644
--- a/apps/editor/src/markdown/plugins/previewHighlight.ts
+++ b/apps/editor/src/markdown/plugins/previewHighlight.ts
@@ -1,40 +1,75 @@
 import { MdNode, MdPos } from '@toast-ui/toastmark';
 import { Plugin } from 'prosemirror-state';
 import { MdContext } from '@t/spec';
-import { ToolbarState, ToolbarStateKeys } from '@t/ui';
+import { ToolbarStateMap, ToolbarStateKeys } from '@t/ui';
 import { traverseParentNodes, isListNode } from '@/utils/markdown';
+import { includes } from '@/utils/common';
+
+const defaultToolbarStateKeys: ToolbarStateKeys[] = [
+  'taskList',
+  'orderedList',
+  'bulletList',
+  'table',
+  'strong',
+  'emph',
+  'strike',
+  'heading',
+  'thematicBreak',
+  'blockQuote',
+  'code',
+  'codeBlock',
+  'indent',
+  'outdent',
+];
 
 function getToolbarStateType(mdNode: MdNode) {
+  const { type } = mdNode;
+
   if (isListNode(mdNode)) {
     if (mdNode.listData.task) {
       return 'taskList';
     }
     return mdNode.listData.type === 'ordered' ? 'orderedList' : 'bulletList';
   }
-  if (mdNode.type.indexOf('table') !== -1) {
+
+  if (type.indexOf('table') !== -1) {
     return 'table';
   }
-  return mdNode.type;
+
+  if (!includes(defaultToolbarStateKeys, type)) {
+    return null;
+  }
+
+  return type as ToolbarStateKeys;
 }
 
 function getToolbarState(targetNode: MdNode) {
-  const toolbarState = {} as ToolbarState;
+  const toolbarState = {
+    indent: { active: false, disabled: true },
+    outdent: { active: false, disabled: true },
+  } as ToolbarStateMap;
+
   let listEnabled = true;
 
   traverseParentNodes(targetNode, (mdNode) => {
     const type = getToolbarStateType(mdNode);
 
-    if (type === 'customBlock' || type === 'image' || type === 'link') {
+    if (!type) {
       return;
     }
 
     if (type === 'bulletList' || type === 'orderedList') {
+      // to apply the nearlist list state in the nested list
       if (listEnabled) {
-        toolbarState[type] = true;
+        toolbarState[type] = { active: true };
+
+        toolbarState.indent.disabled = false;
+        toolbarState.outdent.disabled = false;
+
         listEnabled = false;
       }
     } else {
-      toolbarState[type as ToolbarStateKeys] = true;
+      toolbarState[type as ToolbarStateKeys] = { active: true };
     }
   });
 
diff --git a/apps/editor/src/ui/components/toolbar/buttonHoc.ts b/apps/editor/src/ui/components/toolbar/buttonHoc.ts
index 16772443..5e0ccd8d 100644
--- a/apps/editor/src/ui/components/toolbar/buttonHoc.ts
+++ b/apps/editor/src/ui/components/toolbar/buttonHoc.ts
@@ -6,7 +6,7 @@ import {
   SetItemWidth,
   ComponentClass,
   ToolbarButtonInfo,
-  ToolbarState,
+  ToolbarStateMap,
 } from '@t/ui';
 import { Emitter } from '@t/event';
 import html from '@/ui/vdom/template';
@@ -24,11 +24,12 @@ interface Props {
 }
 
 interface Payload {
-  toolbarState: ToolbarState;
+  toolbarState: ToolbarStateMap;
 }
 
 interface State {
   active: boolean;
+  disabled: boolean;
 }
 
 const TOOLTIP_INDENT = 6;
@@ -37,7 +38,7 @@ export function connectHOC(WrappedComponent: ComponentClass) {
   return class ButtonHOC extends Component<Props, State> {
     constructor(props: Props) {
       super(props);
-      this.state = { active: false };
+      this.state = { active: false, disabled: props.disabled };
       this.addEvent();
     }
 
@@ -46,9 +47,9 @@ export function connectHOC(WrappedComponent: ComponentClass) {
 
       if (item.state) {
         eventEmitter.listen('changeToolbarState', ({ toolbarState }: Payload) => {
-          const active = !!toolbarState[item.state!];
+          const { active, disabled } = toolbarState[item.state!] ?? {};
 
-          this.setState({ active });
+          this.setState({ active: !!active, disabled: disabled ?? this.props.disabled });
         });
       }
     }
@@ -87,6 +88,7 @@ export function connectHOC(WrappedComponent: ComponentClass) {
           showTooltip=${this.showTooltip}
           hideTooltip=${this.hideTooltip}
           getBound=${this.getBound}
+          disabled=${this.state.disabled || this.props.disabled}
         />
       `;
     }
diff --git a/apps/editor/src/ui/toolbarItemFactory.ts b/apps/editor/src/ui/toolbarItemFactory.ts
index dd48e8b3..addee829 100644
--- a/apps/editor/src/ui/toolbarItemFactory.ts
+++ b/apps/editor/src/ui/toolbarItemFactory.ts
@@ -190,6 +190,7 @@ function createDefaultToolbarItemInfo(type: string) {
         className: 'indent',
         command: 'indent',
         tooltip: i18n.get('Indent'),
+        state: 'indent',
       };
       break;
     case 'outdent':
@@ -198,6 +199,7 @@ function createDefaultToolbarItemInfo(type: string) {
         className: 'outdent',
         command: 'outdent',
         tooltip: i18n.get('Outdent'),
+        state: 'outdent',
       };
       break;
     case 'scrollSync':
diff --git a/apps/editor/src/wysiwyg/plugins/toolbarState.ts b/apps/editor/src/wysiwyg/plugins/toolbarState.ts
index 00488969..6cb52a08 100644
--- a/apps/editor/src/wysiwyg/plugins/toolbarState.ts
+++ b/apps/editor/src/wysiwyg/plugins/toolbarState.ts
@@ -3,7 +3,7 @@ import { Plugin, Selection } from 'prosemirror-state';
 
 import { includes } from '@/utils/common';
 
-import { ToolbarState, ToolbarStateKeys } from '@t/ui';
+import { ToolbarStateMap, ToolbarStateKeys } from '@t/ui';
 import { Emitter } from '@t/event';
 
 type ListType = 'bulletList' | 'orderedList' | 'taskList';
@@ -26,8 +26,8 @@ function getToolbarStateType(node: Node, parentNode: Node) {
   return type;
 }
 
-function setListNodeToolbarState(type: ToolbarStateKeys, nodeTypeState: ToolbarState) {
-  nodeTypeState[type] = true;
+function setListNodeToolbarState(type: ToolbarStateKeys, nodeTypeState: ToolbarStateMap) {
+  nodeTypeState[type] = { active: true };
 
   LIST_TYPES.filter((listName) => listName !== type).forEach((listType) => {
     if (nodeTypeState[listType]) {
@@ -36,26 +36,29 @@ function setListNodeToolbarState(type: ToolbarStateKeys, nodeTypeState: ToolbarS
   });
 }
 
-function getMarkTypeStates(from: ResolvedPos, to: ResolvedPos, schema: Schema) {
-  const markTypeState = {} as ToolbarState;
-
+function setMarkTypeStates(
+  from: ResolvedPos,
+  to: ResolvedPos,
+  schema: Schema,
+  toolbarState: ToolbarStateMap
+) {
   MARK_TYPES.forEach((type) => {
     const mark = schema.marks[type];
     const marksAtPos = from.marksAcross(to) || [];
     const foundMark = !!mark.isInSet(marksAtPos);
 
     if (foundMark) {
-      markTypeState[type as ToolbarStateKeys] = true;
+      toolbarState[type as ToolbarStateKeys] = { active: true };
     }
   });
-
-  return markTypeState;
 }
 
 function getToolbarState(selection: Selection, doc: Node, schema: Schema) {
   const { $from, $to, from, to } = selection;
-  const nodeTypeState = {} as ToolbarState;
-  let markTypeState = {} as ToolbarState;
+  const toolbarState = {
+    indent: { active: false, disabled: true },
+    outdent: { active: false, disabled: true },
+  } as ToolbarStateMap;
 
   doc.nodesBetween(from, to, (node, _, parentNode) => {
     const type = getToolbarStateType(node, parentNode);
@@ -65,18 +68,20 @@ function getToolbarState(selection: Selection, doc: Node, schema: Schema) {
     }
 
     if (includes(LIST_TYPES, type)) {
-      setListNodeToolbarState(type as ToolbarStateKeys, nodeTypeState);
+      setListNodeToolbarState(type as ToolbarStateKeys, toolbarState);
+
+      toolbarState.indent.disabled = false;
+      toolbarState.outdent.disabled = false;
     } else if (type === 'paragraph' || type === 'text') {
-      markTypeState = getMarkTypeStates($from, $to, schema);
+      setMarkTypeStates($from, $to, schema, toolbarState);
     } else {
-      nodeTypeState[type as ToolbarStateKeys] = true;
+      toolbarState[type as ToolbarStateKeys] = { active: true };
     }
   });
-
-  return { ...nodeTypeState, ...markTypeState };
+  return toolbarState;
 }
 
-export function toolbarState(eventEmitter: Emitter) {
+export function toolbarStateHighlight(eventEmitter: Emitter) {
   return new Plugin({
     view() {
       return {
diff --git a/apps/editor/src/wysiwyg/wwEditor.ts b/apps/editor/src/wysiwyg/wwEditor.ts
index 41c4b9d3..8383d634 100644
--- a/apps/editor/src/wysiwyg/wwEditor.ts
+++ b/apps/editor/src/wysiwyg/wwEditor.ts
@@ -12,7 +12,7 @@ import { emitImageBlobHook, pasteImageOnly } from '@/helper/image';
 import { tableSelection } from './plugins/selection/tableSelection';
 import { tableContextMenu } from './plugins/tableContextMenu';
 import { task } from './plugins/task';
-import { toolbarState } from './plugins/toolbarState';
+import { toolbarStateHighlight } from './plugins/toolbarState';
 
 import { CustomBlockView } from './nodeview/customBlockView';
 import { ImageView } from './nodeview/imageView';
@@ -111,7 +111,7 @@ export default class WysiwygEditor extends EditorBase {
       tableSelection(),
       tableContextMenu(this.eventEmitter),
       task(),
-      toolbarState(this.eventEmitter),
+      toolbarStateHighlight(this.eventEmitter),
       ...this.createPluginProps(),
     ]);
   }
diff --git a/apps/editor/types/ui.d.ts b/apps/editor/types/ui.d.ts
index 9494b861..8f032762 100644
--- a/apps/editor/types/ui.d.ts
+++ b/apps/editor/types/ui.d.ts
@@ -15,11 +15,6 @@ export interface ToolbarButtonOptions {
   state?: ToolbarStateKeys;
 }
 
-interface ToolbarCustomItemState {
-  active: boolean;
-  disabled: boolean;
-}
-
 export interface ToolbarCustomOptions {
   name: string;
   tooltip?: string;
@@ -28,7 +23,7 @@ export interface ToolbarCustomOptions {
   hidden?: boolean;
   state?: ToolbarStateKeys;
   onMounted?: (execCommand: ExecCommand) => void;
-  onUpdated?: (toolbarState: ToolbarCustomItemState) => void;
+  onUpdated?: (toolbarState: ToolbarItemState) => void;
 }
 
 export type ToolbarButtonInfo = {
@@ -115,21 +110,28 @@ export interface TabInfo {
   text: string;
 }
 
-interface ToolbarState {
-  taskList: boolean;
-  orderedList: boolean;
-  bulletList: boolean;
-  table: boolean;
-  strong: boolean;
-  emph: boolean;
-  strike: boolean;
-  heading: boolean;
-  thematicBreak: boolean;
-  blockQuote: boolean;
-  code: boolean;
-  codeBlock: boolean;
+interface ToolbarItemState {
+  active: boolean;
+  disabled?: boolean;
+}
+
+interface ToolbarStateMap {
+  taskList: ToolbarItemState;
+  orderedList: ToolbarItemState;
+  bulletList: ToolbarItemState;
+  table: ToolbarItemState;
+  strong: ToolbarItemState;
+  emph: ToolbarItemState;
+  strike: ToolbarItemState;
+  heading: ToolbarItemState;
+  thematicBreak: ToolbarItemState;
+  blockQuote: ToolbarItemState;
+  code: ToolbarItemState;
+  codeBlock: ToolbarItemState;
+  indent: ToolbarItemState;
+  outdent: ToolbarItemState;
 }
-export type ToolbarStateKeys = keyof ToolbarState;
+export type ToolbarStateKeys = keyof ToolbarStateMap;
 
 export type ToolbarItemInfo = ToolbarCustomOptions | ToolbarButtonInfo;
 export type ToolbarGroupInfo = ToolbarItemInfo[] & { hidden?: boolean };


Test patch:
diff --git a/apps/editor/src/__test__/integration/ui/toolbar.spec.ts b/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
index c639e22d..9a9aaddf 100644
--- a/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
+++ b/apps/editor/src/__test__/integration/ui/toolbar.spec.ts
@@ -620,11 +620,11 @@ describe('custom toolbar element', () => {
   });
 
   it('should toggle active state properly when toolbar state is changed', () => {
-    em.emit('changeToolbarState', { toolbarState: { strong: true } });
+    em.emit('changeToolbarState', { toolbarState: { strong: { active: true } } });
 
     expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ active: true }));
 
-    em.emit('changeToolbarState', { toolbarState: { strong: false } });
+    em.emit('changeToolbarState', { toolbarState: { strong: { active: false } } });
 
     expect(onUpdatedSpy).toHaveBeenCalledWith(expect.objectContaining({ active: false }));
   });