Skip to content

Commit

Permalink
Slash menu improvements (#361)
Browse files Browse the repository at this point in the history
* only show icon if it exists

* allow custom icons in slash menu

* select correct autocomplete items when moving cursor

* add ALL keyword support in slash menu

* remove custom icons

* add support for multiple slash commands in blocks

* only show second menu if cell has menu items
  • Loading branch information
hannessolo authored Feb 27, 2025
1 parent 2a2688d commit 1286f65
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 85 deletions.
25 changes: 22 additions & 3 deletions blocks/edit/prose/plugins/slashMenu/keyAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ function insertAutocompleteText(state, dispatch, text) {
const { $cursor } = state.selection;

if (!$cursor) return;
const from = $cursor.before();
const to = $cursor.pos;
const tr = state.tr.replaceWith(from, to, state.schema.text(text));
const tr = state.tr.insert($cursor.pos, state.schema.text(text));
dispatch(tr);
}

Expand Down Expand Up @@ -36,6 +34,27 @@ export function processKeyData(data) {
});
});

// values of "all" block are available (thus copied) in all other blocks, if not explicitly set
const allBlocks = blockMap.get('all');
blockMap.forEach((block, blockName) => {
if (blockName === 'all') return;
allBlocks.forEach((values, key) => {
if (!block.has(key)) {
block.set(key, values);
}
});
});

// the "all" block is also returned as fallback if no values are configured for the queried block
const originalGet = blockMap.get.bind(blockMap);
blockMap.get = (blockName) => {
if (blockMap.has(blockName)) {
return originalGet(blockName);
}

return originalGet('all');
};

return blockMap;
}

Expand Down
9 changes: 6 additions & 3 deletions blocks/edit/prose/plugins/slashMenu/slash-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,18 +192,21 @@ export default class SlashMenu extends LitElement {
return '';
}

const rules = [...sheet.cssRules];

return html`
<div class="slash-menu-items">
${filteredItems.map((item, index) => {
const isColor = isColorCode(item.value);
const hasIcon = rules.find((rule) => rule.cssText.startsWith(`.slash-menu-icon.${item.class}`));
return html`
<div
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
@click=${() => this.handleItemClick(item)}
>
${isColor
? createColorSquare(item.value)
: html`<span class="slash-menu-icon ${item.class || ''}"></span>`}
${isColor ? createColorSquare(item.value) : ''}
${hasIcon ? html`<span class="slash-menu-icon ${item.class}"></span>` : ''}
<span class="slash-menu-label">
${item.title}
</span>
Expand Down
151 changes: 72 additions & 79 deletions blocks/edit/prose/plugins/slashMenu/slashMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getKeyAutocomplete } from './keyAutocomplete.js';
import menuItems from './slashMenuItems.js';
import './slash-menu.js';

const SLASH_COMMAND_REGEX = /^\/(([^/\s]+(?:\s+[^/\s]+)*)\s*([^/\s]*))?$/;
const SLASH_COMMAND_REGEX = /\/(([^/\s]+(?:\s+[^/\s]+)*)\s*([^/\s]*))?$/;
const slashMenuKey = new PluginKey('slashMenu');

function extractArgument(title, command) {
Expand All @@ -14,6 +14,48 @@ function extractArgument(title, command) {
: undefined;
}

// Get the table name if the cursor is in a table cell
const getTableName = ($cursor) => {
const { depth } = $cursor;
let tableCellDepth = -1;

// Search up the tree for a table cell
for (let d = depth; d > 0; d -= 1) {
const node = $cursor.node(d);
if (node.type.name === 'table_cell') {
tableCellDepth = d;
break;
}
}

if (tableCellDepth === -1) return false; // not in a table cell

// Get the row node and cell index
const rowDepth = tableCellDepth - 1;
const tableDepth = rowDepth - 1;
const table = $cursor.node(tableDepth);
const firstRow = table.child(0);
const cellIndex = $cursor.index(tableCellDepth - 1);
const row = $cursor.node(rowDepth);

// Only proceed if we're in the second column
if (!(row.childCount > 1 && cellIndex === 1)) return false;

const firstRowContent = firstRow.child(0).textContent;
const tableNameMatch = firstRowContent.match(/^([a-zA-Z0-9_-]+)(?:\s*\([^)]*\))?$/);

const currentRowFirstColContent = row.child(0).textContent;

if (tableNameMatch) {
return {
tableName: tableNameMatch[1],
keyValue: currentRowFirstColContent,
};
}

return false;
};

class SlashMenuView {
constructor(view) {
this.view = view;
Expand All @@ -32,6 +74,27 @@ class SlashMenuView {
});
}

updateSlashMenuItems(pluginState, $cursor) {
const { tableName, keyValue } = getTableName($cursor);
if (tableName) {
const keyData = pluginState.autocompleteData?.get(tableName);
if (keyData && keyData.get(keyValue)) {
this.menu.items = keyData.get(keyValue);
} else {
this.menu.items = menuItems;
}
}
}

cellHasMenuItems(pluginState, $cursor) {
const { tableName, keyValue } = getTableName($cursor);
if (tableName) {
const keyData = pluginState.autocompleteData?.get(tableName);
return keyData && keyData.get(keyValue);
}
return false;
}

update(view) {
if (!view) return;

Expand All @@ -46,24 +109,22 @@ class SlashMenuView {
}

const textBefore = $cursor.parent.textContent.slice(0, $cursor.parentOffset);
if (!textBefore?.startsWith('/')) {
if (!this.cellHasMenuItems(slashMenuKey.getState(state), $cursor) && !textBefore?.startsWith('/')) {
if (this.menu.visible) this.hide();
return;
}

const match = textBefore.match(SLASH_COMMAND_REGEX);
if (match) {
const showSlashMenu = slashMenuKey?.getState(state)?.showSlashMenu;
if (!this.menu.visible || showSlashMenu) {
const coords = this.view.coordsAtPos($cursor.pos);
this.updateSlashMenuItems(slashMenuKey.getState(state), $cursor);
const coords = this.view.coordsAtPos($cursor.pos);

const viewportCoords = {
left: coords.left + window.pageXOffset,
bottom: coords.bottom + window.pageYOffset,
};
const viewportCoords = {
left: coords.left + window.pageXOffset,
bottom: coords.bottom + window.pageYOffset,
};

this.menu.show(viewportCoords);
}
this.menu.show(viewportCoords);

this.menu.command = match[1] || '';
} else if (this.menu.visible) {
Expand Down Expand Up @@ -105,48 +166,6 @@ class SlashMenuView {
}
}

// Get the table name if the cursor is in a table cell
const getTableName = ($cursor) => {
const { depth } = $cursor;
let tableCellDepth = -1;

// Search up the tree for a table cell
for (let d = depth; d > 0; d -= 1) {
const node = $cursor.node(d);
if (node.type.name === 'table_cell') {
tableCellDepth = d;
break;
}
}

if (tableCellDepth === -1) return false; // not in a table cell

// Get the row node and cell index
const rowDepth = tableCellDepth - 1;
const tableDepth = rowDepth - 1;
const table = $cursor.node(tableDepth);
const firstRow = table.child(0);
const cellIndex = $cursor.index(tableCellDepth - 1);
const row = $cursor.node(rowDepth);

// Only proceed if we're in the second column
if (!(row.childCount > 1 && cellIndex === 1)) return false;

const firstRowContent = firstRow.child(0).textContent;
const tableNameMatch = firstRowContent.match(/^([a-zA-Z0-9_-]+)(?:\s*\([^)]*\))?$/);

const currentRowFirstColContent = row.child(0).textContent;

if (tableNameMatch) {
return {
tableName: tableNameMatch[1],
keyValue: currentRowFirstColContent,
};
}

return false;
};

export default function slashMenu() {
let pluginView = null;

Expand Down Expand Up @@ -177,32 +196,6 @@ export default function slashMenu() {
},
props: {
handleKeyDown(editorView, event) {
const { state } = editorView;
const pluginState = slashMenuKey.getState(state);

if (event.key === '/') {
const { $cursor } = state.selection;

// Check if we're at start of empty line
if ($cursor?.parentOffset === 0 && $cursor?.parent?.textContent === '') {
const { tableName, keyValue } = getTableName($cursor);
if (tableName) {
const keyData = pluginState.autocompleteData?.get(tableName);
if (keyData) {
const values = keyData.get(keyValue);
if (values) {
pluginView.menu.items = values;
}
}
}

const tr = state.tr.setMeta(slashMenuKey, { showSlashMenu: true });
editorView.dispatch(tr);
return false;
}
return false;
}

if (pluginView?.menu.visible) {
if (['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) {
event.preventDefault();
Expand Down

0 comments on commit 1286f65

Please sign in to comment.