diff --git a/blocks/canvas/editor-utils/block-slash.js b/blocks/canvas/editor-utils/block-slash.js new file mode 100644 index 000000000..e44897b84 --- /dev/null +++ b/blocks/canvas/editor-utils/block-slash.js @@ -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 } = {}) { + 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'; + } +} diff --git a/blocks/canvas/editor-utils/command-defs.js b/blocks/canvas/editor-utils/command-defs.js index d1963b35a..bf70a2fc8 100644 --- a/blocks/canvas/editor-utils/command-defs.js +++ b/blocks/canvas/editor-utils/command-defs.js @@ -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); @@ -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; } diff --git a/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js b/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js index 7bc47f4d3..c8c3b9287 100644 --- a/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js +++ b/blocks/canvas/ew-block-library-modal/ew-block-library-modal.js @@ -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'; @@ -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 }, @@ -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); @@ -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); diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js index 2cfeaba65..0b44f35e0 100644 --- a/blocks/canvas/ew-editor-doc/prose.js +++ b/blocks/canvas/ew-editor-doc/prose.js @@ -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', () => { @@ -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 = [], @@ -125,6 +149,7 @@ export default async function initProse({ }); addSyncedListener(wsProvider, canWrite, setEditable); + prefetchBlocksAfterSync(wsProvider, canWrite); registerErrorHandler(ydoc); const yXmlFragment = ydoc.getXmlFragment('prosemirror'); diff --git a/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js index a1b44eecb..5f3f2580f 100644 --- a/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js +++ b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js @@ -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`); @@ -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; @@ -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 }; } @@ -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(); }); diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js index d76a4b8c4..ddf3d743d 100644 --- a/blocks/canvas/ew-panel-extensions/helpers.js +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -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 // --------------------------------------------------------------------------- @@ -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) { diff --git a/test/fixtures/nx/utils/utils.js b/test/fixtures/nx/utils/utils.js index 9dbbc457e..7524bdb7e 100644 --- a/test/fixtures/nx/utils/utils.js +++ b/test/fixtures/nx/utils/utils.js @@ -5,3 +5,17 @@ export const loadStyle = async () => { }; export const DA_ADMIN = 'https://admin.da.live'; + +let _hashState = {}; +const _hashSubscribers = new Set(); +export const hashChange = { + subscribe(fn) { + _hashSubscribers.add(fn); + fn(_hashState); + return () => _hashSubscribers.delete(fn); + }, + _set(state) { + _hashState = state; + _hashSubscribers.forEach((fn) => fn(state)); + }, +}; diff --git a/test/unit/blocks/canvas/editor-utils/block-slash.test.js b/test/unit/blocks/canvas/editor-utils/block-slash.test.js new file mode 100644 index 000000000..17f6bdc76 --- /dev/null +++ b/test/unit/blocks/canvas/editor-utils/block-slash.test.js @@ -0,0 +1,250 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let buildBlockEntries; +let matchBlockEntries; + +before(async () => { + const mod = await import('../../../../../blocks/canvas/editor-utils/block-slash.js'); + buildBlockEntries = mod.buildBlockEntries; + matchBlockEntries = mod.matchBlockEntries; +}); + +function sampleEntries() { + return buildBlockEntries([ + { + name: 'banner', + variants: [ + { name: 'banner', variants: undefined, dom: document.createElement('table') }, + { name: 'banner', variants: 'small, blue', tags: 'promo', dom: document.createElement('table') }, + ], + }, + { + name: 'cards', + variants: [ + { name: 'cards', variants: undefined, dom: document.createElement('table') }, + ], + }, + ]); +} + +describe('buildBlockEntries', () => { + it('flattens blocks and variants into namespaced entries', () => { + const entries = sampleEntries(); + expect(entries).to.have.lengthOf(3); + expect(entries[0].id).to.equal('block:0'); + expect(entries[1].id).to.equal('block:1'); + expect(entries[2].id).to.equal('block:2'); + expect(entries[1].blockName).to.equal('banner'); + expect(entries[1].variants).to.equal('small, blue'); + expect(entries[2].blockName).to.equal('cards'); + }); + + it('tolerates blocks with no variants', () => { + const entries = buildBlockEntries([{ name: 'empty' }]); + expect(entries).to.deep.equal([]); + }); +}); + +describe('matchBlockEntries', () => { + it('returns [] for an empty query', () => { + expect(matchBlockEntries(sampleEntries(), '')).to.deep.equal([]); + expect(matchBlockEntries(sampleEntries(), ' ')).to.deep.equal([]); + }); + + it('matches by block name prefix', () => { + const items = matchBlockEntries(sampleEntries(), 'ban'); + expect(items).to.have.lengthOf(2); + expect(items.every((i) => i.id.startsWith('block:'))).to.be.true; + expect(items[0].icon).to.equal('tableadd'); + }); + + it('carries the variants string as the description', () => { + const items = matchBlockEntries(sampleEntries(), 'blue'); + expect(items).to.have.lengthOf(1); + expect(items[0].label).to.equal('banner'); + expect(items[0].description).to.equal('small, blue'); + }); + + it('matches all whitespace-separated terms (contains-all)', () => { + const items = matchBlockEntries(sampleEntries(), 'banner promo'); + expect(items).to.have.lengthOf(1); + expect(items[0].description).to.equal('small, blue'); + }); + + it('matches against tags', () => { + const items = matchBlockEntries(sampleEntries(), 'promo'); + expect(items).to.have.lengthOf(1); + }); + + it('ranks name-prefix matches above contains-only matches', () => { + const entries = buildBlockEntries([ + { name: 'hero', variants: [{ name: 'hero', variants: 'card', dom: document.createElement('table') }] }, + { name: 'card', variants: [{ name: 'card', variants: undefined, dom: document.createElement('table') }] }, + ]); + const items = matchBlockEntries(entries, 'card'); + // "card" (name prefix) ranks before "hero" (matched only via its "card" variant) + expect(items[0].label).to.equal('card'); + expect(items[1].label).to.equal('hero'); + }); + + it('splits a heading-named variant "Name (variant)" into label + description', () => { + const entries = buildBlockEntries([ + { name: 'banner', variants: [{ name: 'Banner (blue)', dom: document.createElement('table') }] }, + ]); + const items = matchBlockEntries(entries, 'banner'); + expect(items).to.have.lengthOf(1); + expect(items[0].label).to.equal('Banner'); + expect(items[0].description).to.equal('blue'); + }); + + it('still matches on the parenthetical variant text after the split', () => { + const entries = buildBlockEntries([ + { name: 'cards', variants: [{ name: 'Cards (no images)', dom: document.createElement('table') }] }, + ]); + const items = matchBlockEntries(entries, 'no images'); + expect(items).to.have.lengthOf(1); + expect(items[0].label).to.equal('Cards'); + expect(items[0].description).to.equal('no images'); + }); + + it('leaves a plain variant name without a description', () => { + const entries = buildBlockEntries([ + { name: 'hero', variants: [{ name: 'Hero', dom: document.createElement('table') }] }, + ]); + const items = matchBlockEntries(entries, 'hero'); + expect(items[0].label).to.equal('Hero'); + expect(items[0].description).to.be.undefined; + }); + + it('prefers an explicit variants string over parsing the name', () => { + const entries = buildBlockEntries([ + { name: 'banner', variants: [{ name: 'banner', variants: 'small, blue', dom: document.createElement('table') }] }, + ]); + const items = matchBlockEntries(entries, 'banner'); + expect(items[0].label).to.equal('banner'); + expect(items[0].description).to.equal('small, blue'); + }); +}); + +describe('block-slash store', () => { + let ingestBlocks; + let blockItemsForQuery; + let insertBlockItem; + let hasLibrary; + let getState; + let resetBlockLibrary; + + let prefetchBlockLibrary; + let resetBlockLibraryCache; + + before(async () => { + const mod = await import('../../../../../blocks/canvas/editor-utils/block-slash.js'); + ingestBlocks = mod.ingestBlocks; + blockItemsForQuery = mod.blockItemsForQuery; + insertBlockItem = mod.insertBlockItem; + hasLibrary = mod.hasLibrary; + getState = mod.getState; + resetBlockLibrary = mod.resetBlockLibrary; + prefetchBlockLibrary = mod.prefetchBlockLibrary; + ({ resetBlockLibraryCache } = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js')); + }); + + afterEach(() => { + resetBlockLibrary(); + resetBlockLibraryCache(); + }); + + function mockBlocks() { + return [ + { + name: 'banner', + path: '/banner', + loadVariants: Promise.resolve([ + { name: 'banner', variants: 'small, blue', dom: document.createElement('table') }, + ]), + }, + ]; + } + + it('starts idle with no library', () => { + expect(getState()).to.equal('idle'); + expect(hasLibrary()).to.be.false; + expect(blockItemsForQuery('banner')).to.deep.equal([]); + }); + + it('ingests blocks into a queryable ready store', async () => { + await ingestBlocks(mockBlocks()); + expect(getState()).to.equal('ready'); + expect(hasLibrary()).to.be.true; + const items = blockItemsForQuery('ban'); + expect(items).to.have.lengthOf(1); + expect(items[0].description).to.equal('small, blue'); + }); + + it('sets state empty when there are no variants', async () => { + await ingestBlocks([{ name: 'x', path: '/x', loadVariants: Promise.resolve([]) }]); + expect(getState()).to.equal('empty'); + }); + + it('insertBlockItem inserts the entry block into the editor at the cursor', async () => { + const { EditorState } = await import('da-y-wrapper'); + const { getSchema } = await import('da-parser'); + const schema = getSchema(); + + const table = document.createElement('table'); + table.innerHTML = 'herocontent'; + await ingestBlocks([ + { + name: 'hero', + path: '/hero', + loadVariants: Promise.resolve([ + { name: 'hero', variants: undefined, dom: table }, + ]), + }, + ]); + + const doc = schema.nodes.doc.create(null, schema.nodes.paragraph.create()); + let state = EditorState.create({ schema, doc }); + const view = { + get state() { return state; }, + dispatch(tr) { state = state.apply(tr); }, + focus() {}, + }; + + const before = state.doc.content.size; + await insertBlockItem(view, 'block:0'); + // Real insertion through helpers.insertBlock grows the document. + expect(state.doc.content.size).to.be.greaterThan(before); + }); + + it('insertBlockItem is a no-op for an unknown id (never dispatches)', async () => { + await ingestBlocks(mockBlocks()); + const view = { state: {}, dispatch() { throw new Error('should not dispatch'); } }; + let threw = false; + try { + await insertBlockItem(view, 'block:nope'); + } catch { + threw = true; + } + expect(threw).to.be.false; + }); + + it('resetBlockLibrary clears the store', async () => { + await ingestBlocks(mockBlocks()); + resetBlockLibrary(); + expect(getState()).to.equal('idle'); + expect(hasLibrary()).to.be.false; + expect(blockItemsForQuery('ban')).to.deep.equal([]); + }); + + it('prefetchBlockLibrary leaves hasLibrary false and state empty when no blocks extension is configured', async () => { + // The test fixture's fetchDaConfigs returns {}, so fetchExtensions yields no 'blocks' + // extension — this exercises the no-library branch. + await prefetchBlockLibrary({ org: 'testorg', site: 'testsite' }); + expect(hasLibrary()).to.be.false; + expect(getState()).to.equal('empty'); + }); +}); diff --git a/test/unit/blocks/canvas/editor-utils/command-defs.test.js b/test/unit/blocks/canvas/editor-utils/command-defs.test.js new file mode 100644 index 000000000..d0fe1c882 --- /dev/null +++ b/test/unit/blocks/canvas/editor-utils/command-defs.test.js @@ -0,0 +1,139 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let slashMenuItemsForQuery; +let applySlashSelection; +let COMMAND_BY_ID; +let ingestBlocks; +let resetBlockLibrary; + +before(async () => { + const cmd = await import('../../../../../blocks/canvas/editor-utils/command-defs.js'); + slashMenuItemsForQuery = cmd.slashMenuItemsForQuery; + applySlashSelection = cmd.applySlashSelection; + COMMAND_BY_ID = cmd.COMMAND_BY_ID; + const bs = await import('../../../../../blocks/canvas/editor-utils/block-slash.js'); + ingestBlocks = bs.ingestBlocks; + resetBlockLibrary = bs.resetBlockLibrary; +}); + +afterEach(() => resetBlockLibrary()); + +async function seed() { + await ingestBlocks([ + { + name: 'banner', + path: '/banner', + loadVariants: Promise.resolve([ + { name: 'banner', variants: 'small, blue', dom: document.createElement('table') }, + ]), + }, + ]); +} + +const sections = (items) => items.filter((i) => i.section).map((i) => i.section); + +describe('slashMenuItemsForQuery', () => { + it('shows only commands for an empty query (no inline block rows)', () => { + const items = slashMenuItemsForQuery(''); + expect(items.some((i) => i.id && i.id.startsWith('block:'))).to.be.false; + expect(items.some((i) => i.id === 'insert-block')).to.be.true; + }); + + it('hides "Open block library" when no library is configured', () => { + const items = slashMenuItemsForQuery(''); + expect(items.some((i) => i.id === 'open-library')).to.be.false; + }); + + it('shows "Open block library" for the empty menu once a library is ingested', async () => { + await seed(); + const items = slashMenuItemsForQuery(''); + expect(items.some((i) => i.id === 'open-library')).to.be.true; + expect(items.some((i) => i.id === 'insert-block')).to.be.true; + }); + + it('hangs a type-to-search hint off "Open block library" on the empty menu', async () => { + await seed(); + const openLib = slashMenuItemsForQuery('').find((i) => i.id === 'open-library'); + expect(openLib).to.exist; + expect((openLib.description || '').toLowerCase()).to.contain('type a block name'); + }); + + it('shows no hint when no library is configured (no Open block library entry)', () => { + const items = slashMenuItemsForQuery(''); + expect(items.some((i) => i.id === 'open-library')).to.be.false; + }); + + it('drops the Open block library hint once the user starts typing', async () => { + await seed(); + const items = slashMenuItemsForQuery('ban'); + expect(items.some((i) => i.id === 'open-library')).to.be.false; + }); + + it('shows inline block rows for a matching query and prefix-filters block commands out', async () => { + await seed(); + const items = slashMenuItemsForQuery('ban'); + expect(sections(items)).to.include('Blocks'); + expect(items.some((i) => i.id && i.id.startsWith('block:'))).to.be.true; + expect(items.some((i) => i.id === 'open-library')).to.be.false; + expect(items.some((i) => i.id === 'insert-block')).to.be.false; + }); + + it('matches text commands too', () => { + const items = slashMenuItemsForQuery('head'); + expect(sections(items)).to.include('Text'); + expect(items.some((i) => i.id === 'heading-1')).to.be.true; + }); + + it('returns nothing for a non-matching non-empty query so the menu can auto-close', () => { + expect(slashMenuItemsForQuery('zzzznomatch')).to.deep.equal([]); + }); +}); + +describe('applySlashSelection', () => { + it('runs the matching static command with the view', () => { + const target = COMMAND_BY_ID.get('heading-2'); + const stub = sinon.stub(target, 'apply'); + const fakeView = { id: 'view' }; + applySlashSelection(fakeView, 'heading-2'); + expect(stub.calledOnce).to.be.true; + expect(stub.firstCall.args[0]).to.equal(fakeView); + stub.restore(); + }); + + it('routes block: ids to real block insertion', async () => { + const { EditorState } = await import('da-y-wrapper'); + const { getSchema } = await import('da-parser'); + const schema = getSchema(); + + const table = document.createElement('table'); + table.innerHTML = 'bannercontent'; + await ingestBlocks([ + { + name: 'banner', + path: '/banner', + loadVariants: Promise.resolve([ + { name: 'banner', variants: 'small, blue', dom: table }, + ]), + }, + ]); + + const doc = schema.nodes.doc.create(null, schema.nodes.paragraph.create()); + let state = EditorState.create({ schema, doc }); + const view = { + get state() { return state; }, + dispatch(tr) { state = state.apply(tr); }, + focus() {}, + }; + const before = state.doc.content.size; + await applySlashSelection(view, 'block:0'); + expect(state.doc.content.size).to.be.greaterThan(before); + }); + + it('returns undefined for an unknown id', () => { + expect(applySlashSelection({}, 'totally-unknown-id')).to.be.undefined; + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.test.js b/test/unit/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.test.js new file mode 100644 index 000000000..157c80b44 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.test.js @@ -0,0 +1,45 @@ +import { expect } from '@esm-bundle/chai'; +import { EditorState, TextSelection } from 'da-y-wrapper'; +import { getSchema } from 'da-parser'; +import { setNx } from '../../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const schema = getSchema(); + +let getSlashContext; + +before(async () => { + const mod = await import('../../../../../../blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js'); + getSlashContext = mod.getSlashContext; +}); + +function stateWithParagraph(text) { + const para = schema.nodes.paragraph.create(null, text ? schema.text(text) : null); + const doc = schema.nodes.doc.create(null, para); + // doc.content.size is after the paragraph's close bracket; -1 puts cursor inside it + const sel = TextSelection.create(doc, doc.content.size - 1); + return EditorState.create({ schema, doc, selection: sel }); +} + +describe('getSlashContext', () => { + it('returns the query for a single-word slash command', () => { + const ctx = getSlashContext(stateWithParagraph('/banner')); + expect(ctx).to.not.be.null; + expect(ctx.query).to.equal('banner'); + }); + + it('allows spaces for multi-term queries', () => { + const ctx = getSlashContext(stateWithParagraph('/banner blue')); + expect(ctx).to.not.be.null; + expect(ctx.query).to.equal('banner blue'); + }); + + it('returns null when the paragraph does not start with a slash', () => { + expect(getSlashContext(stateWithParagraph('banner'))).to.be.null; + }); + + it('returns null for an over-long query (cap)', () => { + expect(getSlashContext(stateWithParagraph(`/${'x'.repeat(60)}`))).to.be.null; + }); +});