@silexlabs/silex
Version:
Free and easy website builder for everyone.
499 lines (459 loc) • 17.2 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 {html, render, TemplateResult} from 'lit-html'
import {defaultSections, idCodeWrapper, isSite, SettingsSection, updateInfo} from './settings-sections'
/**
* @fileoverview This file contains the settings dialog. The config API lets plugins add sections to the settings dialog.
*/
import { WebsiteData, WebsiteSettings } from '../../types'
import { ClientEvent } from '../events'
import { SILEX_VERSION } from '../../constants'
import { Button, Editor } from 'grapesjs'
const sectionsSite: SettingsSection[] = [...defaultSections]
const sectionsPage: SettingsSection[] = [...defaultSections]
const el: HTMLDivElement = document.createElement('div')
let modal: any | undefined // the modal var should be of type ModalModule, not Modal, but it is not exported from grapesjs
let settingsState: {
isOpen: boolean
title: string
page: unknown
sectionId?: string
sender?: Button
} | null = null
let customSettingsDialog: HTMLDivElement | null = null
export const cmdOpenSettings = 'open-settings'
export const cmdAddSection = 'settings:add-section'
export const cmdRemoveSection = 'settings:remove-section'
export const cmdRenderSection = 'settings:render-section'
export const cmdGetSettings = 'settings:get'
export const cmdSetSettings = 'settings:set'
export interface AddSectionOption {
section: SettingsSection,
siteOrPage: 'site' | 'page',
position: 'first' | 'last' | number
}
let headEditor: ReturnType<Editor['CodeManager']['createViewer']> | null = null
function createCustomSettingsDialog(editor: Editor, opts: Record<string, unknown>, page: unknown, sectionId?: string): HTMLDivElement {
const dialog = document.createElement('div')
dialog.className = 'settings-dialog gjs-two-color'
dialog.innerHTML = `
<div class="settings-content gjs-mdl-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="settings-header">
<h3 id="settings-title">${page ? 'Page settings' : 'Site Settings'}</h3>
<button type="button" class="settings-close" title="Close" aria-label="Close settings">×</button>
</div>
<div class="settings-body"></div>
</div>
`
// Handle close button
const closeButton = dialog.querySelector('.settings-close') as HTMLButtonElement
closeButton.addEventListener('click', () => {
closeCustomSettingsDialog(editor)
})
// Handle keyboard shortcuts
dialog.addEventListener('keydown', (e: KeyboardEvent) => {
// Close on Escape
if (e.key === 'Escape') {
e.preventDefault()
closeCustomSettingsDialog(editor)
return
}
// Save on Ctrl/Cmd + Enter
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
const form = dialog.querySelector('form') as HTMLFormElement
if (form) {
form.requestSubmit()
}
return
}
// Handle Alt+C (Cancel) and Alt+A (Apply)
if (e.altKey) {
if (e.key.toLowerCase() === 'c') {
e.preventDefault()
closeCustomSettingsDialog(editor)
return
}
if (e.key.toLowerCase() === 'a') {
e.preventDefault()
const form = dialog.querySelector('form') as HTMLFormElement
if (form) {
form.requestSubmit()
}
return
}
}
})
// Add content
displaySettings(editor, opts, page, sectionId)
const body = dialog.querySelector('.settings-body') as HTMLDivElement
body.appendChild(el)
// Initialize focus when dialog opens
setTimeout(() => {
const firstTab = dialog.querySelector('.silex-list--menu li[role="button"]') as HTMLElement
if (firstTab) {
firstTab.focus()
}
}, 100)
// Handle form submission
const form = el.querySelector('form') as HTMLFormElement
form.onsubmit = (event: Event) => {
event.preventDefault()
editor.trigger(ClientEvent.SETTINGS_SAVE_START, page)
saveSettings(editor, opts, page)
editor.trigger(ClientEvent.SETTINGS_SAVE_END, page)
closeCustomSettingsDialog(editor, false)
}
return dialog
}
function closeCustomSettingsDialog(editor: Editor, fromCommand = false) {
if (customSettingsDialog) {
customSettingsDialog.remove()
customSettingsDialog = null
}
settingsState?.sender?.set && settingsState.sender.set('active', 0)
// Only call stopCommand if not already being called from the command's stop method
if (!fromCommand) {
editor.stopCommand(cmdOpenSettings)
}
settingsState = null
editor.trigger(ClientEvent.SETTINGS_CLOSE)
}
function reopenSettingsModal(editor: Editor, opts: Record<string, unknown>) {
if (!settingsState) {
return
}
modal = editor.Modal.open({
title: settingsState.title,
content: '',
attributes: { class: 'settings-dialog' },
})
.onceClose(() => {
settingsState?.sender?.set && settingsState.sender.set('active', 0)
editor.stopCommand(cmdOpenSettings)
settingsState = null
})
displaySettings(editor, opts, settingsState.page, settingsState.sectionId)
modal.setContent(el)
const form = el.querySelector('form') as HTMLFormElement
form.onsubmit = (event: Event) => {
event.preventDefault()
editor.trigger(ClientEvent.SETTINGS_SAVE_START, settingsState?.page)
saveSettings(editor, opts, settingsState?.page)
editor.trigger(ClientEvent.SETTINGS_SAVE_END, settingsState?.page)
editor.stopCommand(cmdOpenSettings)
}
}
export const settingsDialog = (
editor: Editor,
opts: Record<string, unknown>
): void => {
// No need to override Modal system - we use custom dialog
editor.Commands.add(cmdOpenSettings, {
run: (
_: Editor,
sender: Button,
{page, sectionId}: {page?: unknown, sectionId?: string}
) => {
const title = page ? 'Page settings' : 'Site Settings'
// Store settings state
settingsState = {
isOpen: true,
title,
page,
sectionId,
sender
}
// Create and show custom dialog instead of using GrapesJS modal
customSettingsDialog = createCustomSettingsDialog(editor, opts, page, sectionId)
document.body.appendChild(customSettingsDialog)
// Let the first focusable element get focus naturally
editor.trigger(ClientEvent.SETTINGS_OPEN, page)
return customSettingsDialog
},
stop: (): void => {
closeCustomSettingsDialog(editor, true)
},
})
editor.Commands.add(cmdAddSection, (_editor: Editor, _sender: Button, options: AddSectionOption): void => {
addSection(options.section, options.siteOrPage, options.position)
})
editor.Commands.add(cmdRenderSection, (_editor: Editor, _sender: Button, options: AddSectionOption): void => {
if (!settingsState) return
displaySettings(editor, opts, settingsState.page, settingsState.sectionId)
})
editor.Commands.add(cmdRemoveSection, (_editor: Editor, _sender: Button, options: AddSectionOption): void => {
removeSection(options.section.id, options.siteOrPage)
})
editor.Commands.add(cmdGetSettings, (_editor: Editor, _sender: Button, options: any = {}) => {
const { page } = options
if (page) {
const p = typeof page === 'string'
? editor.Pages.getAll().find(pp => pp.getName() === page || pp.id === page)
: editor.Pages.getSelected()
if (!p) throw new Error(`Page not found: "${page}". Use pages:list to see all pages.`)
return p.get('settings') || {}
}
return editor.getModel().get('settings') || {}
})
editor.Commands.add(cmdSetSettings, (_editor: Editor, _sender: Button, options: Record<string, unknown> = {}) => {
const { page, ...settings } = options
if (!Object.keys(settings).length) throw new Error('Required: at least one setting key. Valid keys: title, description, favicon, lang, head, og:title, og:description, og:image')
if (page) {
const p = typeof page === 'string'
? editor.Pages.getAll().find(pp => pp.getName() === page || pp.id === page)
: editor.Pages.getSelected()
if (!p) throw new Error(`Page not found: "${page}". Use pages:list to see all pages.`)
const current = (p.get('settings') || {}) as Record<string, unknown>
p.set('settings', { ...current, ...settings })
} else {
const current = (editor.getModel().get('settings') || {}) as Record<string, unknown>
editor.getModel().set('settings', { ...current, ...settings })
}
editor.getModel().set('changesCount', editor.getDirtyCount() + 1)
updateDom(editor)
})
editor.on('storage:start:store', (data: WebsiteData): void => {
data.settings = editor.getModel().get('settings')
/* @ts-ignore FIXME: this should not be there? or is it used on the server side in the sotrage providers? */
data.name = editor.getModel().get('name')
})
editor.on('storage:end:load', (data: WebsiteData): void => {
const model = editor.getModel()
model.set('settings', data.settings || {})
model.set('name', editor.getModel().get('name'))
})
editor.on('canvas:frame:load', (): void => {
updateDom(editor)
updateInfo()
})
editor.on('page', (_e: unknown): void => {
updateDom(editor)
})
headEditor = editor.CodeManager.createViewer({
readOnly: false,
codeName: 'htmlmixed',
lineNumbers: true,
lineWrapping: true,
autoFormat: false,
})
// Register AI capabilities
editor.on('ai-capabilities:ready', (addCapability) => {
addCapability({
id: cmdGetSettings,
command: cmdGetSettings,
description: 'Get site or page settings',
readOnly: true,
inputSchema: {
type: 'object',
properties: {
page: { type: 'string' },
},
},
tags: ['settings'],
})
addCapability({
id: cmdSetSettings,
command: cmdSetSettings,
description: 'Set site or page settings',
inputSchema: {
type: 'object',
properties: {
page: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
favicon: { type: 'string' },
lang: { type: 'string' },
head: { type: 'string' },
'og:title': { type: 'string' },
'og:description': { type: 'string' },
'og:image': { type: 'string' },
},
},
tags: ['settings'],
})
})
}
function showSection(item: SettingsSection): void {
const aside = el.querySelector('aside.silex-bar') as HTMLElement
const ul = aside.querySelector('ul.silex-list--menu') as HTMLUListElement
const li = ul.querySelector('li#settings-sidebar-' + item.id) as HTMLLIElement
currentSection = item
if(!li) {
console.warn('Cannot find section', item.id, 'in the side bar, fallback to the first section')
if(!defaultSections[0] || defaultSections[0].id === item.id) {
console.error('Cannot find the default section in the side bar')
return
}
showSection(defaultSections[0])
return
}
Array.from(ul.querySelectorAll('.active')).forEach(el => el.classList.remove('active'))
li.classList.add('active')
const main = el.querySelector('main#settings__main') as HTMLElement
const mainItem = main.querySelector(`#settings-${item.id}`)
if(!mainItem) {
console.warn('Cannot find section', item.id, 'in the settings dialog, fallback to the first section')
if(!defaultSections[0] || defaultSections[0].id === item.id) {
console.error('Cannot find the default section in the settings dialog')
return
}
showSection(defaultSections[0])
return
}
Array.from(main.querySelectorAll('.silex-hideable')).forEach(el => el.classList.add('silex-hidden'))
mainItem.classList.remove('silex-hidden')
requestAnimationFrame(() => headEditor?.refresh())
}
export function addSection(
section: SettingsSection,
siteOrPage: 'site' | 'page',
position: 'first' | 'last' | number
): void {
const sections = siteOrPage === 'site' ? sectionsSite : sectionsPage
if (position === 'first') {
sections.unshift(section)
} else if (position === 'last') {
sections.push(section)
} else if (typeof position === 'number') {
sections.splice(position, 0, section)
} else {
throw new Error('Invalid position for settings section')
}
}
export function removeSection(
id: string,
siteOrPage: 'site' | 'page'
): void {
const sections = siteOrPage === 'site' ? sectionsSite : sectionsPage
const index = sections.findIndex(section => section.id === id)
if(index === -1) throw new Error(`Cannot find section with id ${id}`)
sections.splice(index, 1)
}
let currentSection: SettingsSection | undefined
function displaySettings(
editor: Editor,
config: Record<string, unknown>,
model: any = editor.getModel(),
sectionId?: string
): void {
const settings: WebsiteSettings = model.get('settings') || {} as WebsiteSettings
model.set('settings', settings)
const menuItemsCurrent: SettingsSection[] = isSite(model) ? sectionsSite : sectionsPage
const targetSection = !!sectionId && menuItemsCurrent.find(section => section.id === sectionId)
currentSection = targetSection || currentSection || menuItemsCurrent[0]
const sections: TemplateResult[] = menuItemsCurrent.map(item => {
try {
return item.render(settings, model)
} catch (e) {
console.error('Error rendering settings section', item.id, e)
return html`<div class="silex-error">Error rendering settings section ${item.id}</div>`
}
})
render(html`
<form class="silex-form">
<section class="silex-sidebar-dialog">
<aside class="silex-bar">
<ul class="silex-list silex-list--menu" aria-label="Settings sections">
${menuItemsCurrent.map((item, index) => html`
<li
id="settings-sidebar-${item.id}"
class=${item.id === currentSection!.id ? 'active' : ''}
role="button"
tabindex="0"
@click=${(e: Event) => {
e.preventDefault()
showSection(item)
editor.trigger(ClientEvent.SETTINGS_SECTION_CHANGE, item.id)
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showSection(item)
editor.trigger(ClientEvent.SETTINGS_SECTION_CHANGE, item.id)
}
}}
>
${item.label}
</li>
`)}
</ul>
</aside>
<main id="settings__main">
${ sections }
</main>
</section>
<footer role="contentinfo">
<p class="silex-version">Silex ${SILEX_VERSION}</p>
<input class="silex-button" type="button" value="Cancel" @click=${() => editor.stopCommand(cmdOpenSettings)} accesskey="c">
<input class="silex-button" type="submit" value="Apply" accesskey="a">
</footer>
</form>
`, el)
showSection(currentSection!)
el.querySelector(`#${idCodeWrapper}`)?.appendChild(headEditor!.getElement())
headEditor!.setContent(settings.head || '')
requestAnimationFrame(() => headEditor?.refresh())
}
function saveSettings(
editor: Editor,
config: Record<string, unknown>,
model: any = editor.getModel()
): void {
const form = el.querySelector('form') as HTMLFormElement
const formData = new FormData(form)
const data = Array.from(formData)
.reduce((aggregate: {[key: string]: any}, [key, value]) => {
if(value !== '') {
aggregate[key] = value
}
return aggregate
}, {})
const { name, ...settings } = data
model.set({
settings: {
...data,
head: headEditor!.getContent(),
},
name,
})
editor.getModel().set('changesCount', editor.getDirtyCount() + 1)
updateDom(editor)
}
function getHeadContainer(doc: Document, className: string): HTMLElement {
const container = doc.head.querySelector(`.${className}`) as HTMLElement | null
if(container) {
return container
}
const newContainer = doc.createElement('div')
newContainer.classList.add(className)
doc.head.appendChild(newContainer)
return newContainer
}
function updateDom(editor: Editor): void {
const doc = editor.Canvas.getDocument()
const settings = editor.getModel().get('settings')
const pageSettings = editor.Pages.getSelected().get('settings') as WebsiteSettings
if(doc && settings) {
setTimeout(() => {
getHeadContainer(doc, 'site-head')
.innerHTML = settings.head || ''
getHeadContainer(doc, 'page-head')
.innerHTML = pageSettings?.head || ''
})
}
}