@silexlabs/silex
Version:
Free and easy website builder for everyone.
422 lines (386 loc) • 14.3 kB
text/typescript
/*
* 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'],
})
})
})
}