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
155 changes: 155 additions & 0 deletions blocks/canvas/editor-utils/block-slash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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);
}

/**
* Lightweight, load-time check (no block/variant fetching) for whether a "blocks"
* library is configured for the org/site. Records the result so the slash menu can
* decide up-front whether to show "Open block library" — avoiding the flicker of
* showing it and then hiding it once an on-demand load settles. The full library
* (blocks + variant HTML) is still fetched lazily via ensureBlockLibrary on "/".
*/
export function checkBlockLibraryConfigured({ org, site } = {}) {
if (!org || !site) {
store.hasLibrary = false;
return null;
}
return (async () => {
try {
const { getBlocksExtension } = await import('../ew-panel-extensions/helpers.js');
store.hasLibrary = !!(await getBlocksExtension(org, site));
} catch {
store.hasLibrary = false;
}
})();
}

/**
* Load the block library on demand — called when the user opens the slash menu.
* Cached per org/site, so repeated calls while typing don't refetch. Static slash
* commands render immediately; block results appear once the returned promise
* settles. Returns the in-flight load promise the first time a load is kicked off
* (so the caller can re-render when it resolves), or `null` when there's nothing
* new to wait on (already loading/loaded, or no org/site).
*/
export function ensureBlockLibrary({ org, site } = {}) {
const key = org && site ? `${org}/${site}` : null;
if (store.key === key && store.state !== 'idle') return null;
store.key = key;
if (!key) {
store.entries = [];
store.hasLibrary = false;
store.state = 'empty';
return null;
}
store.state = 'loading';
return (async () => {
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';
}
})();
}
56 changes: 44 additions & 12 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, getState } from './block-slash.js';

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

Expand Down Expand Up @@ -408,18 +409,49 @@ 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' },
];
function toSlashItem(cmd, extra) {
const item = { id: cmd.id, label: cmd.label, icon: cmd.icon };
return extra ? { ...item, ...extra } : item;
}

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();

// the library is fetched on demand when the slash menu opens. static commands
// (Text, Insert block) render immediately - block results stream in once the
// async load settles.
const loading = getState() === 'loading';

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))
.map((c) => (c.id === 'open-library' && !raw.trim()
// On the bare "/" menu, hint that typing searches the configured library.
? toSlashItem(c, { description: 'Or type a block name to search' })
: toSlashItem(c)));
const blockItems = [...blockRows, ...blockCmds];

const showLoading = loading && !!raw.trim() && !blockRows.length;

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

const out = [];
if (blockItems.length) {
out.push({ section: 'Blocks' }, ...blockItems);
} else if (showLoading) {
out.push({ section: 'Loading blocks…' });
}
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
17 changes: 16 additions & 1 deletion blocks/canvas/ew-editor-doc/prose.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import base64Uploader from './prose-plugins/base64Uploader.js';
import { getNx } from '../../../scripts/utils.js';
import { getAuthToken } from '../../shared/utils.js';
import { generateColor, getCollabIdentity } from './utils/collab.js';
import { checkBlockLibraryConfigured } from '../editor-utils/block-slash.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 +67,19 @@ function addSyncedListener(wsProvider, canWrite, setEditable) {
wsProvider.on('synced', handleSynced);
}

function checkLibraryConfiguredOnSync(wsProvider, canWrite) {
if (!canWrite) return;
const handleSynced = (isSynced) => {
if (!isSynced) return;
wsProvider.off('synced', handleSynced);
const unsub = hashChange.subscribe((s) => {
if (s?.org && s?.site) checkBlockLibraryConfigured({ org: s.org, site: s.site });
});
unsub?.();
};
wsProvider.on('synced', handleSynced);
}

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

addSyncedListener(wsProvider, canWrite, setEditable);
checkLibraryConfiguredOnSync(wsProvider, canWrite);
registerErrorHandler(ydoc);

const yXmlFragment = ydoc.getXmlFragment('prosemirror');
Expand Down
Loading
Loading