Skip to content
Open
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
119 changes: 119 additions & 0 deletions blocks/canvas/editor-utils/block-slash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const BLOCK_ICON = 'tableadd';

export function buildBlockEntries(resolvedBlocks) {
const entries = [];
(resolvedBlocks || []).forEach((block) => {
(block.variants || []).forEach((variant) => {
entries.push({
id: `block:${entries.length}`,
blockName: block.name,
variantName: variant.name,
variants: variant.variants,
tags: variant.tags,
description: variant.description,
dom: variant.dom,
});
});
});
return entries;
}

function deriveDisplay(entry) {
const name = entry.variantName || entry.blockName || '';
if (entry.variants) return { label: name, description: entry.variants };
const match = name.match(/^(.*\S)\s*\(([^)]+)\)\s*$/);
if (match) return { label: match[1].trim(), description: match[2].trim() };
return { label: name, description: undefined };
}

export function matchBlockEntries(entries, query) {
const terms = (query || '').toLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return [];

return (entries || [])
.map((entry, index) => {
const { label, description } = deriveDisplay(entry);
const haystack = [entry.blockName, entry.variantName, entry.variants, entry.tags]
.filter(Boolean).join(' ').toLowerCase();
if (!terms.every((term) => haystack.includes(term))) return null;
const rank = label.toLowerCase().startsWith(terms[0]) ? 0 : 1;
return { entry, label, description, index, rank };
})
.filter(Boolean)
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map(({ entry, label, description }) => ({
id: entry.id,
label,
description: description || undefined,
icon: BLOCK_ICON,
}));
}

const store = {
entries: [],
state: 'idle', // 'idle' | 'loading' | 'ready' | 'empty'
hasLibrary: false,
key: null,
};

export function getState() {
return store.state;
}

export function hasLibrary() {
return store.hasLibrary;
}

export function blockItemsForQuery(query) {
return matchBlockEntries(store.entries, query);
}

export function resetBlockLibrary() {
store.entries = [];
store.state = 'idle';
store.hasLibrary = false;
store.key = null;
}

export async function ingestBlocks(blocks) {
const resolved = await Promise.all(
(blocks || []).map(async (block) => ({
name: block.name,
variants: (await block.loadVariants) || [],
})),
);
store.entries = buildBlockEntries(resolved);
store.state = store.entries.length ? 'ready' : 'empty';
store.hasLibrary = true;
return store.entries;
}

export async function insertBlockItem(view, id) {
const entry = store.entries.find((e) => e.id === id);
if (!entry) return;
const { insertBlock } = await import('../ew-panel-extensions/helpers.js');
insertBlock(view, entry.dom);
}

export async function prefetchBlockLibrary({ org, site } = {}) {
Comment thread
usman-khalid marked this conversation as resolved.
if (!org || !site) return;
const key = `${org}/${site}`;
if (store.key === key && store.state !== 'idle') return;
store.key = key;
store.state = 'loading';
try {
const { loadBlockLibrary } = await import('../ew-panel-extensions/helpers.js');
const { ext, blocks } = await loadBlockLibrary(org, site);
if (!ext) {
store.entries = [];
store.hasLibrary = false;
store.state = 'empty';
return;
}

store.hasLibrary = true;
await ingestBlocks(blocks);
} catch {
store.state = 'empty';
}
}
42 changes: 29 additions & 13 deletions blocks/canvas/editor-utils/command-defs.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
removeLink,
} from './command-helpers.js';
import { openLinkDialog, openAltDialog, triggerAddImage } from './selection-toolbar.js';
import { blockItemsForQuery, hasLibrary, insertBlockItem } from './block-slash.js';

const notImageSelected = (state) => !isImageNodeSelected(state);

Expand Down Expand Up @@ -408,18 +409,33 @@ export function commandsFor(showIn) {

export const COMMAND_BY_ID = new Map(COMMANDS.map((c) => [c.id, c]));

const SLASH_GROUPS = [
{ section: 'Blocks', showIn: 'slash-blocks' },
{ section: 'Text', showIn: 'slash-text' },
];

export function slashMenuItemsForQuery(query) {
const q = (query || '').toLowerCase();
const groups = SLASH_GROUPS
.map(({ section, showIn }) => ({
section,
items: commandsFor(showIn).filter((i) => !q || i.label.toLowerCase().startsWith(q)),
}))
.filter((g) => g.items.length > 0);
return groups.flatMap(({ section, items }) => [{ section }, ...items]);
const raw = query || '';
const q = raw.toLowerCase();

const blockRows = raw.trim() ? blockItemsForQuery(raw) : [];
const blockCmds = commandsFor('slash-blocks')
.filter((c) => (c.id !== 'open-library' || hasLibrary()))
.filter((c) => !q || c.label.toLowerCase().startsWith(q))
// On the bare "/" menu, hang a hint off "Open block library" so users
// discover they can type a block name to search the configured library.
.map((c) => (c.id === 'open-library' && !raw.trim()
? { ...c, description: 'Or type a block name to search' }
: c));
const blockItems = [...blockRows, ...blockCmds];

const textItems = commandsFor('slash-text')
.filter((c) => !q || c.label.toLowerCase().startsWith(q));

const out = [];
if (blockItems.length) out.push({ section: 'Blocks' }, ...blockItems);
if (textItems.length) out.push({ section: 'Text' }, ...textItems);
return out;
}

export function applySlashSelection(view, id) {
const command = COMMAND_BY_ID.get(id);
if (command) return command.apply(view);
if (id?.startsWith('block:')) return insertBlockItem(view, id);
return undefined;
}
27 changes: 11 additions & 16 deletions blocks/canvas/ew-block-library-modal/ew-block-library-modal.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { LitElement, html, nothing } from 'da-lit';
import { getNx } from '../../../scripts/utils.js';
import {
fetchBlocks,
fetchExtensions,
loadBlockLibrary,
getItemPreviewUrl,
getPreviewStatus,
} from '../ew-panel-extensions/helpers.js';
Expand Down Expand Up @@ -38,7 +37,7 @@ function variantSearchText(block, variant) {

class EwBlockLibraryModal extends LitElement {
static properties = {
extension: { attribute: false },
blocks: { attribute: false },
onInsert: { attribute: false },
_blocks: { state: true },
_variantsByPath: { state: true },
Expand All @@ -64,18 +63,15 @@ class EwBlockLibraryModal extends LitElement {
}

willUpdate(changed) {
if (changed.has('extension') && this.extension) {
this._loadBlocks();
if (changed.has('blocks')) {
this._populateBlocks(this.blocks);
}
}

async _loadBlocks() {
this._blocks = undefined;
if (!this.extension) return;
const blocks = await fetchBlocks(this.extension.sources);
this._blocks = blocks;
// Prefetch variants for every block so search can match against them.
blocks.forEach(async (block) => {
_populateBlocks(blocks) {
this._blocks = blocks || [];
// Resolve every block's variants so search can match against them.
(blocks || []).forEach(async (block) => {
if (this._variantsByPath.has(block.path)) return;
const variants = await block.loadVariants;
const next = new Map(this._variantsByPath);
Expand Down Expand Up @@ -328,14 +324,13 @@ export async function openBlockLibraryModal({ onInsert } = {}) {
const unsub = hashChange.subscribe((s) => { hashState = s; });
unsub();
const { org, site } = hashState || {};
if (!org || !site) return;

const extensions = await fetchExtensions(org, site);
const ext = extensions?.find((e) => e.name === 'blocks');
// Reuses the slash-menu prefetch cache, so this is instant once warmed.
const { ext, blocks } = await loadBlockLibrary(org, site);
if (!ext) return;

const modal = document.createElement('ew-block-library-modal');
modal.extension = ext;
modal.blocks = blocks;
modal.onInsert = onInsert;
modal.addEventListener('close', () => modal.remove(), { once: true });
document.body.append(modal);
Expand Down
27 changes: 26 additions & 1 deletion blocks/canvas/ew-editor-doc/prose.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { getNx } from '../../../scripts/utils.js';
import { getAuthToken } from '../../shared/utils.js';
import { generateColor, getCollabIdentity } from './utils/collab.js';

const { DA_ADMIN, DA_COLLAB } = await import(`${getNx()}/utils/utils.js`);
const { DA_ADMIN, DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`);

function registerErrorHandler(ydoc) {
ydoc.on('update', () => {
Expand All @@ -66,6 +66,30 @@ function addSyncedListener(wsProvider, canWrite, setEditable) {
wsProvider.on('synced', handleSynced);
}

function prefetchBlocksAfterSync(wsProvider, canWrite) {
if (!canWrite) return;
const handleSynced = (isSynced) => {
if (!isSynced) return;
wsProvider.off('synced', handleSynced);

let hashState;
const unsub = hashChange.subscribe((s) => { hashState = s; });
unsub();
if (!hashState?.org || !hashState?.site) return;

const kick = async () => {
const { prefetchBlockLibrary } = await import('../editor-utils/block-slash.js');
prefetchBlockLibrary({ org: hashState.org, site: hashState.site });
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(kick, { timeout: 2000 });
} else {
setTimeout(kick, 0);
}
};
wsProvider.on('synced', handleSynced);
}

export default async function initProse({
path, permissions, setEditable, getToken,
extraPlugins = [],
Expand Down Expand Up @@ -125,6 +149,7 @@ export default async function initProse({
});

addSyncedListener(wsProvider, canWrite, setEditable);
prefetchBlocksAfterSync(wsProvider, canWrite);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could wait until the user types / to fetch this? Probably there would be enough time int he time it takes to type the block name?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this - but IMO power users are pretty quick if they know what they want from the slash menu. Currently prefetches after the doc is loaded which should be fine? @mhaack thoughts?

registerErrorHandler(ydoc);

const yXmlFragment = ydoc.getXmlFragment('prosemirror');
Expand Down
14 changes: 6 additions & 8 deletions blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable import/no-unresolved -- importmap */
import { Plugin } from 'da-y-wrapper';
import { getNx } from '../../../../scripts/utils.js';
import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.js';
import { slashMenuItemsForQuery, applySlashSelection } from '../../editor-utils/command-defs.js';

await import(`${getNx()}/blocks/shared/menu/menu.js`);

Expand All @@ -11,7 +11,7 @@ function inTopLevelParagraph($from) {
return $from.node($from.depth - 1).type.name === 'doc';
}

function getSlashContext(state) {
export function getSlashContext(state) {
const { $from } = state.selection;
if (!inTopLevelParagraph($from)) return null;

Expand All @@ -23,7 +23,7 @@ function getSlashContext(state) {
if (!prefix.startsWith('/')) return null;

const query = prefix.slice(1);
if (/\s/.test(query)) return null;
if (query.length > 50) return null;

return { query, anchorPos: paraStart };
}
Expand All @@ -50,15 +50,13 @@ function setup(container, view) {
container.append(menu);

menu.addEventListener('select', (e) => {
const run = COMMAND_BY_ID.get(e.detail.id)?.apply;
const { state } = view;
const slash = getSlashContext(state);
if (slash && run) {
if (slash) {
const { anchorPos } = slash;
const head = state.selection.from;
const tr = state.tr.delete(anchorPos, head);
view.dispatch(tr);
run(view);
view.dispatch(state.tr.delete(anchorPos, head));
applySlashSelection(view, e.detail.id);
}
view.focus();
});
Expand Down
39 changes: 39 additions & 0 deletions blocks/canvas/ew-panel-extensions/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ export async function fetchExtensions(org, site) {
return extensions;
}

/** Resolve the configured "blocks" library extension for an org/site, or null. */
export async function getBlocksExtension(org, site) {
if (!org || !site) return null;
const extensions = await fetchExtensions(org, site);
return extensions?.find((ext) => ext.name === 'blocks') || null;
}

// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
Expand All @@ -281,6 +288,38 @@ export async function fetchBlocks(sources) {
return blocks;
}

const blockLibraryCache = new Map();

/**
* Load — and memoize per org/site — the configured blocks library: the resolved
* "blocks" extension plus its fetched blocks (each carrying a lazy `loadVariants`
* promise). Shared by the slash-menu prefetch and the block-library modal so the
* library (and every variant's HTML) is fetched and parsed at most once.
* Resolves to `{ ext: null, blocks: [] }` when no library is configured.
*/
export function loadBlockLibrary(org, site) {
if (!org || !site) return Promise.resolve({ ext: null, blocks: [] });
const key = `${org}/${site}`;
if (!blockLibraryCache.has(key)) {
const pending = (async () => {
const ext = await getBlocksExtension(org, site);
if (!ext) return { ext: null, blocks: [] };
const blocks = await fetchBlocks(ext.sources);
return { ext, blocks };
})().catch((err) => {
// Don't cache transient failures — allow a later retry.
blockLibraryCache.delete(key);
throw err;
});
blockLibraryCache.set(key, pending);
}
return blockLibraryCache.get(key);
}

export function resetBlockLibraryCache() {
blockLibraryCache.clear();
}

export async function fetchItems(sources, format) {
const items = [];
for (const source of sources) {
Expand Down
Loading
Loading