Skip to content

Commit 3d223cc

Browse files
authored
feat(YfmTabs): switch between tabs using tabs-extension runtime (#550)
1 parent 4f0dd8c commit 3d223cc

File tree

5 files changed

+89
-168
lines changed

5 files changed

+89
-168
lines changed

src/extensions/yfm/YfmTabs/actions.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import type {Command} from 'prosemirror-state';
33

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

6-
import {TabAttrs, TabPanelAttrs, TabsAttrs} from './YfmTabsSpecs/const';
7-
import {tabActiveClassname, tabPanelActiveClassname} from './const';
8-
9-
import {tabPanelType, tabType, tabsListType, tabsType} from '.';
6+
import {
7+
TabAttrs,
8+
TabPanelAttrs,
9+
TabsAttrs,
10+
tabActiveClassname,
11+
tabPanelActiveClassname,
12+
tabPanelType,
13+
tabType,
14+
tabsListType,
15+
tabsType,
16+
} from './const';
1017

1118
export const createYfmTabsCommand: Command = (state, dispatch) => {
1219
if (dispatch) {

src/extensions/yfm/YfmTabs/const.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
export {TabsNode, tabType, tabPanelType, tabsListType, tabsType} from './YfmTabsSpecs';
1+
export * from './YfmTabsSpecs/const';
2+
export {tabType, tabPanelType, tabsListType, tabsType} from './YfmTabsSpecs';
23

3-
export const tabActiveClassname = 'yfm-tab active';
4-
export const tabInactiveClassname = 'yfm-tab';
4+
export const YFM_TAB_CLASSNAME = 'yfm-tab';
5+
export const DIPLODOC_ID_ATTR = 'data-diplodoc-id';
6+
7+
export const tabActiveClassname = `${YFM_TAB_CLASSNAME} active`;
8+
export const tabInactiveClassname = YFM_TAB_CLASSNAME;
59
export const tabPanelActiveClassname = 'yfm-tab-panel active';
610
export const tabPanelInactiveClassname = 'yfm-tab-panel';

src/extensions/yfm/YfmTabs/plugins.ts

Lines changed: 18 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import {generateID} from '@diplodoc/transform/lib/plugins/utils';
2-
import {Command, Plugin, PluginView, TextSelection, Transaction} from 'prosemirror-state';
3-
import type {Transform} from 'prosemirror-transform';
2+
import {type Command, Plugin, type PluginView, TextSelection} from 'prosemirror-state';
43
import {
5-
NodeWithPos,
4+
type NodeWithPos,
65
findChildren,
76
findDomRefAtPos,
87
findParentNodeOfType,
@@ -24,7 +23,6 @@ import {get$Cursor, isTextSelection} from '../../../utils/selection';
2423

2524
import {TabAttrs, TabPanelAttrs} from './YfmTabsSpecs/const';
2625
import {
27-
tabActiveClassname,
2826
tabInactiveClassname,
2927
tabPanelActiveClassname,
3028
tabPanelInactiveClassname,
@@ -33,7 +31,7 @@ import {
3331
tabsListType,
3432
tabsType,
3533
} from './const';
36-
import {atEndOfPanel} from './utils';
34+
import {atEndOfPanel, execAfterPaint, switchTabByElem, switchTabById} from './utils';
3735

3836
export const dragAutoSwitch = () =>
3937
new Plugin({
@@ -75,10 +73,10 @@ class TabsAutoSwitchOnDragOver implements PluginView {
7573
const pos = view.posAtCoords({left: event.clientX, top: event.clientY});
7674
if (pos) {
7775
const elem = findDomRefAtPos(pos.pos, view.domAtPos.bind(view)) as HTMLElement;
78-
const cutElem = elem.closest(TabsAutoSwitchOnDragOver.TAB_SELECTOR);
79-
if (cutElem === this._tabElem) return;
76+
const tabElem = elem.closest(TabsAutoSwitchOnDragOver.TAB_SELECTOR);
77+
if (tabElem === this._tabElem) return;
8078
this._clear();
81-
if (cutElem) this._setTabElem(cutElem as HTMLElement);
79+
if (tabElem) this._setTabElem(tabElem as HTMLElement);
8280
}
8381
}
8482

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

9997
private _switchTab() {
10098
if (this._editorView.dragging && this._tabElem) {
101-
const pos = this._editorView.posAtDOM(this._tabElem, 0, -1);
102-
const $pos = this._editorView.state.doc.resolve(pos);
103-
const {state} = this._editorView;
104-
105-
let {depth} = $pos;
106-
let tabId = '';
107-
let tabsNode: NodeWithPos | null = null;
108-
do {
109-
const node = $pos.node(depth);
110-
if (node.type === tabType(state.schema)) {
111-
tabId = node.attrs[TabAttrs.dataDiplodocid];
112-
continue;
113-
}
114-
115-
if (node.type === tabsType(state.schema)) {
116-
tabsNode = {node, pos: $pos.before(depth)};
117-
break;
118-
}
119-
} while (--depth >= 0);
120-
121-
if (tabId && tabsNode) {
122-
const {tr} = state;
123-
if (switchYfmTab(tabsNode, tabId, tr)) {
124-
this._editorView.dispatch(tr.setMeta('addToHistory', false));
125-
}
126-
}
99+
switchTabByElem(this._tabElem);
127100
}
128101
this._clear();
129102
}
130103
}
131104

132-
function switchYfmTab(
133-
{node: tabsNode, pos: tabsPos}: NodeWithPos,
134-
tabId: string,
135-
tr: Transform,
136-
): boolean {
137-
const {schema} = tabsNode.type;
138-
if (tabsNode.type !== tabsType(schema)) return false;
139-
140-
const tabsList = tabsNode.firstChild;
141-
if (tabsList?.type !== tabsListType(schema)) return false;
142-
143-
const tabsListPos = tabsPos + 1;
144-
145-
let panelId: string | null = null;
146-
tabsList.forEach((node, offset) => {
147-
if (node.type !== tabType(schema)) return;
148-
149-
const tabPos = tabsListPos + 1 + offset;
150-
const tabAttrs = {
151-
...node.attrs,
152-
[TabAttrs.ariaSelected]: 'false',
153-
[TabAttrs.dataDiplodocIsActive]: 'false',
154-
};
155-
156-
if (node.attrs[TabAttrs.dataDiplodocid] === tabId) {
157-
panelId = node.attrs[TabAttrs.ariaControls];
158-
tabAttrs[TabAttrs.ariaSelected] = 'true';
159-
tabAttrs[TabAttrs.dataDiplodocIsActive] = 'true';
160-
}
161-
162-
tr.setNodeMarkup(tabPos, null, tabAttrs);
163-
});
164-
165-
if (!panelId) return false;
166-
167-
tabsNode.forEach((node, offset) => {
168-
if (node.type !== tabPanelType(schema)) return;
169-
170-
const tabPanelPos = tabsPos + 1 + offset;
171-
const tabPanelAttrs = {
172-
...node.attrs,
173-
};
174-
const tabPanelClassList = new Set(
175-
((node.attrs[TabPanelAttrs.class] as string) ?? '')
176-
.split(' ')
177-
.filter((val) => Boolean(val.trim())),
178-
);
179-
180-
if (node.attrs[TabPanelAttrs.id] === panelId) {
181-
tabPanelClassList.add('active');
182-
} else {
183-
tabPanelClassList.delete('active');
184-
}
185-
186-
tabPanelAttrs[TabPanelAttrs.class] = Array.from(tabPanelClassList).join(' ');
187-
tr.setNodeMarkup(tabPanelPos, null, tabPanelAttrs);
188-
});
189-
190-
return true;
191-
}
192-
193105
export const tabPanelArrowDown: Command = (state, dispatch, view) => {
194106
const {selection: sel} = state;
195107
const tabsParentNode = findParentNodeOfType(tabsType(state.schema))(state.selection);
@@ -253,36 +165,6 @@ export const liftEmptyBlockFromTabPanel: Command = (state, dispatch) => {
253165
return false;
254166
};
255167

256-
const makeTabsInactive = (tabNodes: NodeWithPos[], tabPanels: NodeWithPos[], tr: Transaction) => {
257-
// Find all active tabs and make them inactive
258-
const activeTabs = tabNodes.filter(
259-
(v) => v.node.attrs[TabAttrs.dataDiplodocIsActive] === 'true',
260-
);
261-
262-
if (activeTabs.length) {
263-
activeTabs.forEach((tab) => {
264-
tr.setNodeMarkup(tab.pos, null, {
265-
...tab.node.attrs,
266-
class: tabInactiveClassname,
267-
[TabAttrs.dataDiplodocIsActive]: 'false',
268-
});
269-
});
270-
}
271-
272-
// Find all active panels and make them inactive
273-
const activePanels = tabPanels.filter(
274-
(v) => v.node.attrs[TabPanelAttrs.class] === tabPanelActiveClassname,
275-
);
276-
if (activePanels.length) {
277-
activePanels.forEach((tabPanel) => {
278-
tr.setNodeMarkup(tr.mapping.map(tabPanel.pos), null, {
279-
...tabPanel.node.attrs,
280-
class: tabPanelInactiveClassname,
281-
});
282-
});
283-
}
284-
};
285-
286168
export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) => Command =
287169
(afterTab, tabsParentNode) => (state, dispatch, view) => {
288170
const tabNodes = findChildren(
@@ -307,16 +189,16 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>
307189
{
308190
[TabPanelAttrs.ariaLabelledby]: tabId,
309191
[TabPanelAttrs.id]: panelId,
310-
[TabPanelAttrs.class]: tabPanelActiveClassname,
192+
[TabPanelAttrs.class]: tabPanelInactiveClassname,
311193
},
312194
pType(state.schema).createAndFill(),
313195
);
314196
const newTab = tabType(state.schema).create({
315197
[TabAttrs.id]: tabId,
316198
[TabAttrs.dataDiplodocid]: tabId,
317199
[TabAttrs.dataDiplodocKey]: tabId,
318-
[TabAttrs.dataDiplodocIsActive]: 'true',
319-
[TabAttrs.class]: tabActiveClassname,
200+
[TabAttrs.dataDiplodocIsActive]: 'false',
201+
[TabAttrs.class]: tabInactiveClassname,
320202
[TabAttrs.role]: 'tab',
321203
[TabAttrs.ariaControls]: panelId,
322204
});
@@ -332,8 +214,6 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>
332214
v.pos = v.pos + tabsParentNode.pos + 1;
333215
});
334216

335-
makeTabsInactive(tabNodes, tabPanels, tr);
336-
337217
dispatch?.(
338218
tr
339219
.insert(afterPanelNode.pos + afterPanelNode.node.nodeSize, newPanel)
@@ -345,6 +225,10 @@ export const createTab: (afterTab: NodeWithPos, tabsParentNode: NodeWithPos) =>
345225

346226
view?.focus();
347227

228+
if (view) {
229+
execAfterPaint(() => switchTabById(view.dom, tabId));
230+
}
231+
348232
return true;
349233
};
350234

@@ -388,33 +272,22 @@ export const removeTab: (tabToRemove: NodeWithPos, tabsParentNode: NodeWithPos)
388272
});
389273

390274
const newTabNode = tabNodes[newTabIdx];
391-
392-
const newTabPanelNode = tabPanels[newTabIdx];
393-
394-
makeTabsInactive(tabNodes, tabPanels, tr);
275+
const newActiveTabId: string = newTabNode.node.attrs[TabAttrs.dataDiplodocid];
395276

396277
tr
397278
// Delete panel
398279
.delete(panelToRemove.pos, panelToRemove.pos + panelToRemove.node.nodeSize)
399280
// Delete tab
400281
.delete(tabToRemove.pos, tabToRemove.pos + tabToRemove.node.nodeSize)
401-
// Set new active tab
402-
.setNodeMarkup(tr.mapping.map(newTabNode.pos), null, {
403-
...newTabNode.node.attrs,
404-
class: tabActiveClassname,
405-
[TabAttrs.dataDiplodocIsActive]: 'true',
406-
})
407-
// Set new active panel
408-
.setNodeMarkup(tr.mapping.map(newTabPanelNode.pos), null, {
409-
...newTabPanelNode.node.attrs,
410-
class: tabPanelActiveClassname,
411-
})
412282
.setSelection(
413283
TextSelection.create(
414284
tr.doc,
415285
tr.mapping.map(newTabNode.pos + newTabNode.node.nodeSize - 1),
416286
),
417287
);
288+
289+
// Set new active tab
290+
if (view) execAfterPaint(() => switchTabById(view.dom, newActiveTabId));
418291
}
419292
dispatch(tr);
420293
view?.focus();

src/extensions/yfm/YfmTabs/utils.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import {EditorView} from 'prosemirror-view';
1+
import type {EditorView} from 'prosemirror-view';
22

3-
import {tabPanelType} from '.';
3+
import {DIPLODOC_ID_ATTR, YFM_TAB_CLASSNAME, tabPanelType} from './const';
4+
5+
export const execAfterPaint = (fn: () => void) => {
6+
requestAnimationFrame(() => {
7+
requestAnimationFrame(fn);
8+
});
9+
};
410

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

1824
return null;
1925
};
26+
27+
export const switchTabByElem = (tabElem: HTMLElement) => {
28+
if (tabElem.classList.contains(YFM_TAB_CLASSNAME)) {
29+
tabElem.click();
30+
}
31+
};
32+
33+
export const switchTabById = (container: HTMLElement, tabId: string) => {
34+
const selector = `.${YFM_TAB_CLASSNAME}[${DIPLODOC_ID_ATTR}="${tabId}"]`;
35+
const tabElem = container.querySelector<HTMLDivElement>(selector);
36+
if (tabElem) {
37+
switchTabByElem(tabElem);
38+
}
39+
};

src/extensions/yfm/YfmTabs/views.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,27 @@
1-
import {Node} from 'prosemirror-model';
2-
import {EditorState} from 'prosemirror-state';
1+
import type {Node} from 'prosemirror-model';
2+
import {type EditorState, TextSelection} from 'prosemirror-state';
33
import {findParentNodeOfTypeClosestToPos} from 'prosemirror-utils';
4-
import {EditorView, NodeViewConstructor} from 'prosemirror-view';
4+
import {EditorView, type NodeViewConstructor} from 'prosemirror-view';
55

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

8+
import {tabType, tabsType} from './const';
89
import {crossSvg, plusSvg} from './icons';
910
import {createTab, removeTab} from './plugins';
10-
11-
import {tabType, tabsType} from '.';
11+
import {execAfterPaint} from './utils';
1212

1313
import './index.scss';
1414

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

1717
const ignoreMutation =
18-
(node: Node, view: EditorView, getPos: () => number | undefined) =>
18+
(_node: Node, _view: EditorView, _getPos: () => number | undefined) =>
1919
(mutation: MutationRecord) => {
2020
if (
2121
mutation instanceof MutationRecord &&
2222
mutation.type === 'attributes' &&
2323
mutation.attributeName
2424
) {
25-
const newAttr = (mutation.target as HTMLElement).getAttribute(mutation.attributeName);
26-
27-
view.dispatch(
28-
view.state.tr.setNodeMarkup(getPos()!, null, {
29-
...node.attrs,
30-
[mutation.attributeName]: newAttr,
31-
}),
32-
);
3325
return true;
3426
}
3527

@@ -60,6 +52,31 @@ export const tabView: NodeViewConstructor = (node, view, getPos) => {
6052
wrapperElem.addEventListener('click', () => {
6153
// Click on parent node to trigger event listener that selects current tab
6254
tabElem.click();
55+
56+
{
57+
/**
58+
* Hack for empty tabs
59+
*
60+
* Problem: when clicking on an empty tab (without text content) it focuses, and selection doesn't move to beginning of tab
61+
*
62+
* Temporary fix: manually return focus to pm-view, move text selection to beginning of tab
63+
*/
64+
65+
view.focus();
66+
67+
// tab is empty
68+
if (node.nodeSize < 3) {
69+
execAfterPaint(() => {
70+
const pos = getPos();
71+
if (pos !== undefined) {
72+
const {tr} = view.state;
73+
view.dispatch(
74+
tr.setSelection(TextSelection.create(tr.doc, pos + 1)).scrollIntoView(),
75+
);
76+
}
77+
});
78+
}
79+
}
6380
});
6481

6582
const removeTabButton = document.createElement('div');

0 commit comments

Comments
 (0)