Skip to content

Commit

Permalink
feat(YfmTabs): switch between tabs using tabs-extension runtime (#550)
Browse files Browse the repository at this point in the history
  • Loading branch information
d3m1d0v authored Jan 22, 2025
1 parent 4f0dd8c commit 3d223cc
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 168 deletions.
15 changes: 11 additions & 4 deletions src/extensions/yfm/YfmTabs/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import type {Command} from 'prosemirror-state';

import {pType} from '../../base/BaseSchema';

import {TabAttrs, TabPanelAttrs, TabsAttrs} from './YfmTabsSpecs/const';
import {tabActiveClassname, tabPanelActiveClassname} from './const';

import {tabPanelType, tabType, tabsListType, tabsType} from '.';
import {
TabAttrs,
TabPanelAttrs,
TabsAttrs,
tabActiveClassname,
tabPanelActiveClassname,
tabPanelType,
tabType,
tabsListType,
tabsType,
} from './const';

export const createYfmTabsCommand: Command = (state, dispatch) => {
if (dispatch) {
Expand Down
10 changes: 7 additions & 3 deletions src/extensions/yfm/YfmTabs/const.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export {TabsNode, tabType, tabPanelType, tabsListType, tabsType} from './YfmTabsSpecs';
export * from './YfmTabsSpecs/const';
export {tabType, tabPanelType, tabsListType, tabsType} from './YfmTabsSpecs';

export const tabActiveClassname = 'yfm-tab active';
export const tabInactiveClassname = 'yfm-tab';
export const YFM_TAB_CLASSNAME = 'yfm-tab';
export const DIPLODOC_ID_ATTR = 'data-diplodoc-id';

export const tabActiveClassname = `${YFM_TAB_CLASSNAME} active`;
export const tabInactiveClassname = YFM_TAB_CLASSNAME;
export const tabPanelActiveClassname = 'yfm-tab-panel active';
export const tabPanelInactiveClassname = 'yfm-tab-panel';
163 changes: 18 additions & 145 deletions src/extensions/yfm/YfmTabs/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {generateID} from '@diplodoc/transform/lib/plugins/utils';
import {Command, Plugin, PluginView, TextSelection, Transaction} from 'prosemirror-state';
import type {Transform} from 'prosemirror-transform';
import {type Command, Plugin, type PluginView, TextSelection} from 'prosemirror-state';
import {
NodeWithPos,
type NodeWithPos,
findChildren,
findDomRefAtPos,
findParentNodeOfType,
Expand All @@ -24,7 +23,6 @@ import {get$Cursor, isTextSelection} from '../../../utils/selection';

import {TabAttrs, TabPanelAttrs} from './YfmTabsSpecs/const';
import {
tabActiveClassname,
tabInactiveClassname,
tabPanelActiveClassname,
tabPanelInactiveClassname,
Expand All @@ -33,7 +31,7 @@ import {
tabsListType,
tabsType,
} from './const';
import {atEndOfPanel} from './utils';
import {atEndOfPanel, execAfterPaint, switchTabByElem, switchTabById} from './utils';

export const dragAutoSwitch = () =>
new Plugin({
Expand Down Expand Up @@ -75,10 +73,10 @@ class TabsAutoSwitchOnDragOver implements PluginView {
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
if (pos) {
const elem = findDomRefAtPos(pos.pos, view.domAtPos.bind(view)) as HTMLElement;
const cutElem = elem.closest(TabsAutoSwitchOnDragOver.TAB_SELECTOR);
if (cutElem === this._tabElem) return;
const tabElem = elem.closest(TabsAutoSwitchOnDragOver.TAB_SELECTOR);
if (tabElem === this._tabElem) return;
this._clear();
if (cutElem) this._setTabElem(cutElem as HTMLElement);
if (tabElem) this._setTabElem(tabElem as HTMLElement);
}
}

Expand All @@ -98,98 +96,12 @@ class TabsAutoSwitchOnDragOver implements PluginView {

private _switchTab() {
if (this._editorView.dragging && this._tabElem) {
const pos = this._editorView.posAtDOM(this._tabElem, 0, -1);
const $pos = this._editorView.state.doc.resolve(pos);
const {state} = this._editorView;

let {depth} = $pos;
let tabId = '';
let tabsNode: NodeWithPos | null = null;
do {
const node = $pos.node(depth);
if (node.type === tabType(state.schema)) {
tabId = node.attrs[TabAttrs.dataDiplodocid];
continue;
}

if (node.type === tabsType(state.schema)) {
tabsNode = {node, pos: $pos.before(depth)};
break;
}
} while (--depth >= 0);

if (tabId && tabsNode) {
const {tr} = state;
if (switchYfmTab(tabsNode, tabId, tr)) {
this._editorView.dispatch(tr.setMeta('addToHistory', false));
}
}
switchTabByElem(this._tabElem);
}
this._clear();
}
}

function switchYfmTab(
{node: tabsNode, pos: tabsPos}: NodeWithPos,
tabId: string,
tr: Transform,
): boolean {
const {schema} = tabsNode.type;
if (tabsNode.type !== tabsType(schema)) return false;

const tabsList = tabsNode.firstChild;
if (tabsList?.type !== tabsListType(schema)) return false;

const tabsListPos = tabsPos + 1;

let panelId: string | null = null;
tabsList.forEach((node, offset) => {
if (node.type !== tabType(schema)) return;

const tabPos = tabsListPos + 1 + offset;
const tabAttrs = {
...node.attrs,
[TabAttrs.ariaSelected]: 'false',
[TabAttrs.dataDiplodocIsActive]: 'false',
};

if (node.attrs[TabAttrs.dataDiplodocid] === tabId) {
panelId = node.attrs[TabAttrs.ariaControls];
tabAttrs[TabAttrs.ariaSelected] = 'true';
tabAttrs[TabAttrs.dataDiplodocIsActive] = 'true';
}

tr.setNodeMarkup(tabPos, null, tabAttrs);
});

if (!panelId) return false;

tabsNode.forEach((node, offset) => {
if (node.type !== tabPanelType(schema)) return;

const tabPanelPos = tabsPos + 1 + offset;
const tabPanelAttrs = {
...node.attrs,
};
const tabPanelClassList = new Set(
((node.attrs[TabPanelAttrs.class] as string) ?? '')
.split(' ')
.filter((val) => Boolean(val.trim())),
);

if (node.attrs[TabPanelAttrs.id] === panelId) {
tabPanelClassList.add('active');
} else {
tabPanelClassList.delete('active');
}

tabPanelAttrs[TabPanelAttrs.class] = Array.from(tabPanelClassList).join(' ');
tr.setNodeMarkup(tabPanelPos, null, tabPanelAttrs);
});

return true;
}

export const tabPanelArrowDown: Command = (state, dispatch, view) => {
const {selection: sel} = state;
const tabsParentNode = findParentNodeOfType(tabsType(state.schema))(state.selection);
Expand Down Expand Up @@ -253,36 +165,6 @@ export const liftEmptyBlockFromTabPanel: Command = (state, dispatch) => {
return false;
};

const makeTabsInactive = (tabNodes: NodeWithPos[], tabPanels: NodeWithPos[], tr: Transaction) => {
// Find all active tabs and make them inactive
const activeTabs = tabNodes.filter(
(v) => v.node.attrs[TabAttrs.dataDiplodocIsActive] === 'true',
);

if (activeTabs.length) {
activeTabs.forEach((tab) => {
tr.setNodeMarkup(tab.pos, null, {
...tab.node.attrs,
class: tabInactiveClassname,
[TabAttrs.dataDiplodocIsActive]: 'false',
});
});
}

// Find all active panels and make them inactive
const activePanels = tabPanels.filter(
(v) => v.node.attrs[TabPanelAttrs.class] === tabPanelActiveClassname,
);
if (activePanels.length) {
activePanels.forEach((tabPanel) => {
tr.setNodeMarkup(tr.mapping.map(tabPanel.pos), null, {
...tabPanel.node.attrs,
class: tabPanelInactiveClassname,
});
});
}
};

export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) => Command =
(afterTab, tabsParentNode) => (state, dispatch, view) => {
const tabNodes = findChildren(
Expand All @@ -307,16 +189,16 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>
{
[TabPanelAttrs.ariaLabelledby]: tabId,
[TabPanelAttrs.id]: panelId,
[TabPanelAttrs.class]: tabPanelActiveClassname,
[TabPanelAttrs.class]: tabPanelInactiveClassname,
},
pType(state.schema).createAndFill(),
);
const newTab = tabType(state.schema).create({
[TabAttrs.id]: tabId,
[TabAttrs.dataDiplodocid]: tabId,
[TabAttrs.dataDiplodocKey]: tabId,
[TabAttrs.dataDiplodocIsActive]: 'true',
[TabAttrs.class]: tabActiveClassname,
[TabAttrs.dataDiplodocIsActive]: 'false',
[TabAttrs.class]: tabInactiveClassname,
[TabAttrs.role]: 'tab',
[TabAttrs.ariaControls]: panelId,
});
Expand All @@ -332,8 +214,6 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>
v.pos = v.pos + tabsParentNode.pos + 1;
});

makeTabsInactive(tabNodes, tabPanels, tr);

dispatch?.(
tr
.insert(afterPanelNode.pos + afterPanelNode.node.nodeSize, newPanel)
Expand All @@ -345,6 +225,10 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>

view?.focus();

if (view) {
execAfterPaint(() => switchTabById(view.dom, tabId));
}

return true;
};

Expand Down Expand Up @@ -388,33 +272,22 @@ export const removeTab: (tabToRemove: NodeWithPos, tabsParentNode: NodeWithPos)
});

const newTabNode = tabNodes[newTabIdx];

const newTabPanelNode = tabPanels[newTabIdx];

makeTabsInactive(tabNodes, tabPanels, tr);
const newActiveTabId: string = newTabNode.node.attrs[TabAttrs.dataDiplodocid];

tr
// Delete panel
.delete(panelToRemove.pos, panelToRemove.pos + panelToRemove.node.nodeSize)
// Delete tab
.delete(tabToRemove.pos, tabToRemove.pos + tabToRemove.node.nodeSize)
// Set new active tab
.setNodeMarkup(tr.mapping.map(newTabNode.pos), null, {
...newTabNode.node.attrs,
class: tabActiveClassname,
[TabAttrs.dataDiplodocIsActive]: 'true',
})
// Set new active panel
.setNodeMarkup(tr.mapping.map(newTabPanelNode.pos), null, {
...newTabPanelNode.node.attrs,
class: tabPanelActiveClassname,
})
.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(newTabNode.pos + newTabNode.node.nodeSize - 1),
),
);

// Set new active tab
if (view) execAfterPaint(() => switchTabById(view.dom, newActiveTabId));
}
dispatch(tr);
view?.focus();
Expand Down
24 changes: 22 additions & 2 deletions src/extensions/yfm/YfmTabs/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {EditorView} from 'prosemirror-view';
import type {EditorView} from 'prosemirror-view';

import {tabPanelType} from '.';
import {DIPLODOC_ID_ATTR, YFM_TAB_CLASSNAME, tabPanelType} from './const';

export const execAfterPaint = (fn: () => void) => {
requestAnimationFrame(() => {
requestAnimationFrame(fn);
});
};

export const atEndOfPanel = (view?: EditorView) => {
if (!view) return null;
Expand All @@ -17,3 +23,17 @@ export const atEndOfPanel = (view?: EditorView) => {

return null;
};

export const switchTabByElem = (tabElem: HTMLElement) => {
if (tabElem.classList.contains(YFM_TAB_CLASSNAME)) {
tabElem.click();
}
};

export const switchTabById = (container: HTMLElement, tabId: string) => {
const selector = `.${YFM_TAB_CLASSNAME}[${DIPLODOC_ID_ATTR}="${tabId}"]`;
const tabElem = container.querySelector<HTMLDivElement>(selector);
if (tabElem) {
switchTabByElem(tabElem);
}
};
45 changes: 31 additions & 14 deletions src/extensions/yfm/YfmTabs/views.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,27 @@
import {Node} from 'prosemirror-model';
import {EditorState} from 'prosemirror-state';
import type {Node} from 'prosemirror-model';
import {type EditorState, TextSelection} from 'prosemirror-state';
import {findParentNodeOfTypeClosestToPos} from 'prosemirror-utils';
import {EditorView, NodeViewConstructor} from 'prosemirror-view';
import {EditorView, type NodeViewConstructor} from 'prosemirror-view';

import {cn} from '../../../classname';

import {tabType, tabsType} from './const';
import {crossSvg, plusSvg} from './icons';
import {createTab, removeTab} from './plugins';

import {tabType, tabsType} from '.';
import {execAfterPaint} from './utils';

import './index.scss';

const cnYfmTab = cn('yfm-tab');

const ignoreMutation =
(node: Node, view: EditorView, getPos: () => number | undefined) =>
(_node: Node, _view: EditorView, _getPos: () => number | undefined) =>
(mutation: MutationRecord) => {
if (
mutation instanceof MutationRecord &&
mutation.type === 'attributes' &&
mutation.attributeName
) {
const newAttr = (mutation.target as HTMLElement).getAttribute(mutation.attributeName);

view.dispatch(
view.state.tr.setNodeMarkup(getPos()!, null, {
...node.attrs,
[mutation.attributeName]: newAttr,
}),
);
return true;
}

Expand Down Expand Up @@ -60,6 +52,31 @@ export const tabView: NodeViewConstructor = (node, view, getPos) => {
wrapperElem.addEventListener('click', () => {
// Click on parent node to trigger event listener that selects current tab
tabElem.click();

{
/**
* Hack for empty tabs
*
* Problem: when clicking on an empty tab (without text content) it focuses, and selection doesn't move to beginning of tab
*
* Temporary fix: manually return focus to pm-view, move text selection to beginning of tab
*/

view.focus();

// tab is empty
if (node.nodeSize < 3) {
execAfterPaint(() => {
const pos = getPos();
if (pos !== undefined) {
const {tr} = view.state;
view.dispatch(
tr.setSelection(TextSelection.create(tr.doc, pos + 1)).scrollIntoView(),
);
}
});
}
}
});

const removeTabButton = document.createElement('div');
Expand Down

0 comments on commit 3d223cc

Please sign in to comment.