UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

422 lines (386 loc) 14.3 kB
/* * Silex website builder, free/libre no-code tool for makers. * Copyright (c) 2023 lexoyo and Silex Labs foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { Component, Editor, Page } from 'grapesjs' import {html, render} from 'lit-html' import {ref} from 'lit-html/directives/ref.js' const pluginName = 'page-panel' let open export const cmdTogglePages = 'pages:open-panel' export const cmdAddPage = 'pages:add' export const cmdRemovePage = 'pages:remove' export const cmdClonePage = 'pages:clone' export const cmdSelectNextPage = 'pages:select-next' export const cmdSelectPrevPage = 'pages:select-prev' export const cmdSelectFirstPage = 'pages:select-first' export const cmdListPages = 'pages:list' export const cmdSelectPage = 'pages:select' export const cmdDeletePage = 'pages:delete' export const cmdRenamePage = 'pages:rename' function selectPage(editor: Editor, page: Page) { editor.Pages.select(page) } function addPage(editor: Editor, config: { newPageName: string, cmdOpenNewPageDialog: string }) { const pages = editor.Pages.getAll() // Get a name let idx = 1 const newPageName = config.newPageName || 'New page' let pageName = newPageName while(pages.find(p => p.getName() === pageName)) { pageName = `${newPageName} ${idx++}` } // Add page const page = editor.Pages.add({ name: pageName }) // Select the new page editor.Pages.select(page) // Open page settings to edit the name editor.runCommand(config.cmdOpenNewPageDialog, {page}) } function clonePage(editor: Editor, page: Page) { const pages = editor.Pages.getAll() // Get a name let idx = 1 const newPageName = (page.getName() || 'main') + ' copy' let pageName = newPageName while(pages.find(p => p.getName() === pageName)) { pageName = `${newPageName} ${idx++}` } // Clone components using Backbone approach const sourceBody = page.getMainComponent() const clonedComponents = sourceBody.clone() // Get custom props but exclude frames (Backbone collection) using toJSON and delete const customProps = { ...page.toJSON() } delete customProps.frames delete customProps.id delete customProps._undo // Create new page with custom props (but not frames collection) const newPage = editor.Pages.add({ ...customProps, name: pageName, }) // Reset components with cloned ones const targetBody = newPage.getMainComponent() targetBody.components().reset(clonedComponents.components().models) // Select the new page editor.Pages.select(newPage) } function removePage(editor, page) { if(editor.Pages.getAll().length === 1) { console.error('can not delete the only page') } else { const isMain = page.get('type') === 'main' const isSelected = editor.Pages.getSelected() === page editor.Pages.remove(page.id) const firstPage = editor.Pages.getAll()[0] if(isMain) firstPage.set('type', 'main') if(isSelected) selectPage(editor, firstPage) } } function removePageWithConfirm(editor, page) { const content = document.createElement('div') const modal = editor.Modal.open({ title: 'Are you sure?', content, }) render(html` <p>Do you really want to remove this page?</p> <footer> <button ${ref((el: HTMLButtonElement) => { setTimeout(() => el.focus()) })} @click=${() => { removePage(editor, page) modal.close() }} class="silex-button silex-button--primary">Delete page</button> <button @click=${() => modal.close()} class="silex-button silex-button--secondary">Cancel</button </footer> `, content) } function selectNextPage(editor) { const pages = editor.Pages.getAll() const selected = editor.Pages.getSelected() const idx = pages.indexOf(selected) if(idx < pages.length - 1) { selectPage(editor, pages[idx + 1]) } else { selectPage(editor, pages[0]) } } function selectPrevPage(editor) { const pages = editor.Pages.getAll() const selected = editor.Pages.getSelected() const idx = pages.indexOf(selected) if(idx > 0) { selectPage(editor, pages[idx - 1]) } else { selectPage(editor, pages[pages.length - 1]) } } function settingsPage(editor, config, page) { editor.runCommand(config.cmdOpenSettings, {page}) } function renderPages(editor, config) { const pages = editor.Pages.getAll() const selected = editor.Pages.getSelected() const getPageFromEvent = (e) => { const el = e.target.hasAttribute('data-page-id') ? e.target : e.target.parentNode e.stopPropagation() return editor.Pages.get(el.getAttribute('data-page-id')) } // Drag and drop handlers let draggedPage: Page | null = null let draggedPageElement: HTMLElement | null = null const handleDragStart = (e: DragEvent, page: Page) => { e.stopPropagation() draggedPage = page // Find the parent page element draggedPageElement = (e.target as HTMLElement).closest('.pages__page') as HTMLElement if (e.dataTransfer && draggedPageElement) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/html', draggedPageElement.innerHTML) draggedPageElement.classList.add('pages__page-dragging') } } const handleDragEnd = (e: DragEvent) => { if (draggedPageElement) { draggedPageElement.classList.remove('pages__page-dragging') } draggedPage = null draggedPageElement = null } const handleDragOver = (e: DragEvent) => { if (!draggedPage) return e.preventDefault() e.stopPropagation() if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move' } return false } const handleDragEnter = (e: DragEvent, page: Page) => { if (draggedPage && draggedPage !== page) { e.preventDefault() e.stopPropagation() } } const handleDragLeave = (e: DragEvent) => { // No-op: visual indicator removed } const handleDrop = (e: DragEvent, targetPage: Page) => { e.stopPropagation() e.preventDefault() if (draggedPage && draggedPage !== targetPage) { const pages = editor.Pages.getAll() const fromIndex = pages.indexOf(draggedPage) const toIndex = pages.indexOf(targetPage) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { // Access the internal Backbone collection const pagesCollection = (editor.Pages as any).pages // Assign temporary order attributes for sorting pages.forEach((page, idx) => { let newOrder = idx if (idx === fromIndex) { newOrder = toIndex } else if (fromIndex < toIndex && idx > fromIndex && idx <= toIndex) { newOrder = idx - 1 } else if (fromIndex > toIndex && idx >= toIndex && idx < fromIndex) { newOrder = idx + 1 } page.set('_tempOrder', newOrder, { silent: true }) }) // Sort the collection by the temporary order const originalComparator = pagesCollection.comparator pagesCollection.comparator = (page: Page) => page.get('_tempOrder') pagesCollection.sort() pagesCollection.comparator = originalComparator // Clean up temporary attributes pages.forEach(page => page.unset('_tempOrder', { silent: true })) } } return false } return html`<section class="pages"> <main class="pages__main ${pages.length === 1 ? 'pages__single-page' : ''}"> <div class="pages__list"> ${ pages.map((page: Page, index: number) => { const type = page.get('type') || index === 0 ? 'main' : 'Untitled Page' const name = page.getName() || type // keep the same structure as the layers panel return html` <div class="pages__page ${selected === page ? 'pages__page-selected' : ''}" data-page-id=${page.id} @click=${e => selectPage(editor, getPageFromEvent(e))} @dragover=${handleDragOver} @dragenter=${(e: DragEvent) => handleDragEnter(e, page)} @dragleave=${handleDragLeave} @drop=${(e: DragEvent) => handleDrop(e, page)} > <div class="pages__page-name"> <i class="pages__icon pages__drag-handle fa fa-grip-vertical" draggable="true" @dragstart=${(e: DragEvent) => handleDragStart(e, page)} @dragend=${handleDragEnd} ></i> ${ name } </div> <i class="pages__icon pages__remove-btn fa fa-trash" @click=${e => removePage(editor, getPageFromEvent(e))}></i> <i class="pages__icon pages__clone-btn fa fa-clone" @click=${e => clonePage(editor, getPageFromEvent(e))}></i> <i class="pages__icon fa fa-cog" @click=${e => settingsPage(editor, config, getPageFromEvent(e))}></i> </div> </div> ` })} </div> ${pages.length ? '' : html`<div class="flex-row"> No page yet. </div> `} </main></section>` } export const pagePanelPlugin = (editor: Editor, opts) => { // create wrapper const el = document.createElement('div') el.classList.add('pages__wrapper') // update const doRender = () => render(renderPages(editor, opts), el) editor.on('page', () => { doRender() }) editor.on('load', () => { open = false document.querySelector(opts.appendTo).appendChild(el) doRender() // click anywhere close it // const close = (event) => { // if(open) { // editor.stopCommand(cmdTogglePages) // //event.preventDefault() // } // } // document.addEventListener('mousedown', close) // add useful commands editor.Commands.add(cmdAddPage, () => addPage(editor, opts)) editor.Commands.add(cmdRemovePage, () => removePageWithConfirm(editor, editor.Pages.getSelected())) editor.Commands.add(cmdClonePage, () => clonePage(editor, editor.Pages.getSelected())) editor.Commands.add(cmdSelectNextPage, () => selectNextPage(editor)) editor.Commands.add(cmdSelectPrevPage, () => selectPrevPage(editor)) editor.Commands.add(cmdSelectFirstPage, () => selectPage(editor, editor.Pages.getAll()[0])) editor.Commands.add(cmdListPages, () => { return editor.Pages.getAll().map((p: Page) => ({ id: p.id, name: p.getName(), selected: editor.Pages.getSelected() === p, })) }) editor.Commands.add(cmdSelectPage, (_editor: Editor, _sender: any, options: any = {}) => { const { name, id } = options if (!name && !id) throw new Error('Required: name or id. Use pages:list to see all pages.') const page = id ? editor.Pages.get(id) : editor.Pages.getAll().find((p: Page) => p.getName() === name) if (!page) throw new Error(`Page not found: "${name || id}". Use pages:list to see all pages.`) selectPage(editor, page) }) editor.Commands.add(cmdDeletePage, (_editor: Editor, _sender: any, options: any = {}) => { const { name, id } = options const page = name || id ? (id ? editor.Pages.get(id) : editor.Pages.getAll().find((p: Page) => p.getName() === name)) : editor.Pages.getSelected() if (!page) throw new Error(`Page not found: "${name || id || 'selected'}". Use pages:list to see all pages.`) if (editor.Pages.getAll().length === 1) throw new Error('Cannot delete the only page. At least one page must exist.') removePage(editor, page) }) editor.Commands.add(cmdRenamePage, (_editor: Editor, _sender: any, options: any = {}) => { const { name, id, newName } = options if (!newName) throw new Error('Required: newName (the new page name string)') const page = name || id ? (id ? editor.Pages.get(id) : editor.Pages.getAll().find((p: Page) => p.getName() === name)) : editor.Pages.getSelected() if (!page) throw new Error(`Page not found: "${name || id || 'selected'}". Use pages:list to see all pages.`) page.set('name', newName) }) // Register AI capabilities editor.on('ai-capabilities:ready', (addCapability) => { addCapability({ id: cmdListPages, command: cmdListPages, description: 'List all pages', readOnly: true, tags: ['pages'], }) addCapability({ id: cmdSelectPage, command: cmdSelectPage, description: 'Select a page by name or id', inputSchema: { type: 'object', properties: { name: { type: 'string' }, id: { type: 'string' }, }, }, tags: ['pages'], }) addCapability({ id: cmdAddPage, command: cmdAddPage, description: 'Create a new page', tags: ['pages'], }) addCapability({ id: cmdClonePage, command: cmdClonePage, description: 'Clone the selected page', tags: ['pages'], }) addCapability({ id: cmdDeletePage, command: cmdDeletePage, description: 'Delete a page', destructive: true, inputSchema: { type: 'object', properties: { name: { type: 'string' }, id: { type: 'string' }, }, }, tags: ['pages'], }) addCapability({ id: cmdRenamePage, command: cmdRenamePage, description: 'Rename a page', inputSchema: { type: 'object', required: ['newName'], properties: { name: { type: 'string' }, id: { type: 'string' }, newName: { type: 'string' }, }, }, tags: ['pages'], }) }) }) }