Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/extensions/yfm/YfmTabs/const.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import {nodeTypeFactory} from '../../../utils/schema';

export enum TabsNode {
Tab = 'yfm_tab',
TabsList = 'yfm_tabs_list',
TabPanel = 'yfm_tab_panel',
Tabs = 'yfm_tabs',
}

export const tabActiveClassname = 'yfm-tab active';
export const tabInactiveClassname = 'yfm-tab';
export const tabPanelActiveClassname = 'yfm-tab-panel active';
export const tabPanelInactiveClassname = 'yfm-tab-panel';

export const tabPanelType = nodeTypeFactory(TabsNode.TabPanel);
export const tabType = nodeTypeFactory(TabsNode.Tab);
export const tabsType = nodeTypeFactory(TabsNode.Tabs);
export const tabListType = nodeTypeFactory(TabsNode.TabsList);
40 changes: 32 additions & 8 deletions src/extensions/yfm/YfmTabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ import {TabsNode} from './const';

import {fromYfm} from './fromYfm';
import {spec} from './spec';
import {Node} from 'prosemirror-model';
import {EditorView} from 'prosemirror-view';
import {tabBackspace, tabPanelBackspace} from './plugins';
import {chainCommands} from 'prosemirror-commands';

const ignoreMutation =
(node: Node, view: EditorView, getPos: () => number) => (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]: String(newAttr),
}),
);
return true;
}

return false;
};

export const YfmTabs: ExtensionAuto = (builder) => {
builder
Expand All @@ -19,10 +44,8 @@ export const YfmTabs: ExtensionAuto = (builder) => {
},
// FIX: ignore mutation and don't rerender node when yfm.js switch tab
// @ts-expect-error
view: () => () => ({
ignoreMutation(mutation) {
return mutation instanceof MutationRecord && mutation.type === 'attributes';
},
view: () => (node, view, getPos) => ({
ignoreMutation: ignoreMutation(node, view, getPos),
}),
}))
.addNode(TabsNode.TabsList, () => ({
Expand All @@ -42,10 +65,8 @@ export const YfmTabs: ExtensionAuto = (builder) => {
},
// FIX: ignore mutation and don't rerender node when yfm.js switch tab
// @ts-expect-error
view: () => () => ({
ignoreMutation(mutation) {
return mutation instanceof MutationRecord && mutation.type === 'attributes';
},
view: () => (node, view, getPos) => ({
ignoreMutation: ignoreMutation(node, view, getPos),
}),
}))
.addNode(TabsNode.Tabs, () => ({
Expand All @@ -55,5 +76,8 @@ export const YfmTabs: ExtensionAuto = (builder) => {
tokenSpec: fromYfm[TabsNode.Tabs],
tokenName: 'tabs',
},
}))
.addKeymap(() => ({
Backspace: chainCommands(tabPanelBackspace, tabBackspace),
}));
};
136 changes: 136 additions & 0 deletions src/extensions/yfm/YfmTabs/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {Command, TextSelection} from 'prosemirror-state';
import {findChildren, findParentNodeOfType} from 'prosemirror-utils';
import {
tabActiveClassname,
tabInactiveClassname,
tabListType,
tabPanelActiveClassname,
tabPanelInactiveClassname,
tabPanelType,
tabsType,
tabType,
} from './const';
import {findChildIndex} from '../../../table-utils/helpers';
import {get$Cursor} from '../../../utils/selection';

export const tabPanelBackspace: Command = (state) => {
const $cursor = get$Cursor(state.selection);
if (
$cursor?.node($cursor.depth - 1).type === tabPanelType(state.schema) &&
$cursor.start($cursor.depth - 1) === $cursor.pos - 1
) {
return true;
}
return false;
};

export const tabBackspace: Command = (state, dispatch) => {
const tabToRemove = findParentNodeOfType(tabType(state.schema))(state.selection);
const tabsParentNode = findParentNodeOfType(tabsType(state.schema))(state.selection);

if (
tabsParentNode &&
tabToRemove &&
state.selection.from === tabToRemove.pos + 1 &&
state.selection.from === state.selection.to
) {
const tabList = findChildren(tabsParentNode.node, (tabNode) => {
return tabNode.type.name === tabListType(state.schema).name;
})[0];
const tabToRemoveIdx = findChildIndex(tabList.node, tabToRemove.node);

const tabNodes = findChildren(
tabList.node,
(node) => node.type.name === tabType(state.schema).name,
);

const tabPanels = findChildren(tabsParentNode.node, (tabNode) => {
return tabNode.type.name === tabPanelType(state.schema).name;
});

const panelToRemove = tabPanels.filter(
(tabNode) => tabNode.node.attrs['aria-labelledby'] === tabToRemove.node.attrs['id'],
)[0];

if (panelToRemove && dispatch) {
// Change relative pos to absolute
panelToRemove.pos = panelToRemove.pos + tabsParentNode.pos;
const {tr} = state;

if (tabNodes.length <= 1) {
tr.delete(tabsParentNode.pos, tabsParentNode.pos + tabsParentNode.node.nodeSize);
} else {
const newTabIdx = tabToRemoveIdx - 1 < 0 ? 1 : tabToRemoveIdx - 1;

// Change relative pos to absolute
tabNodes.forEach((v) => {
v.pos = v.pos + tabsParentNode.pos + 2;
});

const newTabNode = tabNodes[newTabIdx];

const newTabPanelNode = tabPanels[newTabIdx];
// Change relative pos to absolute
newTabPanelNode.pos = newTabPanelNode.pos + tabsParentNode.pos + 1;

// Find all active tabs and make them inactive
const activeTabs = tabNodes.filter(
(v) => v.node.attrs['class'] === tabActiveClassname,
);

if (activeTabs.length) {
activeTabs.forEach((tab) => {
tr.setNodeMarkup(tab.pos, null, {
...tab.node.attrs,
class: tabInactiveClassname,
});
});
}

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

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,
})
// 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),
),
);
}
dispatch(tr);

return true;
}
}

return false;
};
4 changes: 2 additions & 2 deletions src/extensions/yfm/YfmTabs/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const spec: Record<TabsNode, NodeSpec> = {
tabindex: {default: 'unknown'},
},
marks: '',
content: 'inline*',
content: 'text*',
group: 'block',
parseDOM: [{tag: 'div.yfm-tab'}],
toDOM(node) {
Expand Down Expand Up @@ -45,7 +45,7 @@ export const spec: Record<TabsNode, NodeSpec> = {
[TabsNode.Tabs]: {
allowGapCursor: true,
attrs: {class: {default: 'unknown'}},
content: 'yfm_tabs_list* yfm_tab_panel*',
content: 'yfm_tabs_list yfm_tab_panel+',
group: 'block',
parseDOM: [{tag: 'div.yfm-tabs'}],
toDOM(node) {
Expand Down
4 changes: 2 additions & 2 deletions src/extensions/yfm/YfmTabs/toYfm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export const toYfm: Record<TabsNode, SerializerNodeToken> = {

tabList.forEach((tab, _, i) => {
state.write('- ' + tab.textContent + '\n\n');
state.renderList(children[i + 1], ' ', () => ' ');
if (children[i + 1]) state.renderList(children[i + 1], ' ', () => ' ');
});

state.write('{% endlist %}');
state.write('{% endlist %}\n\n');
},
[TabsNode.TabsList]: (state, node) => {
state.renderList(node, ' ', () => (node.attrs.bullet || '-') + ' ');
Expand Down