@silexlabs/silex
Version:
Free and easy website builder for everyone.
598 lines (551 loc) • 22.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 grapesjs, { Editor, EditorConfig } from 'grapesjs'
import openImport from './openImport'
/**
* @fileoverview This is where grapes config gets created
* Handle plugins, options and initialization of the editor
*/
const notificationContainer = document.createElement('div')
// ////////////////////
// Plugins
// ////////////////////
import blocksBasicPlugin from 'grapesjs-blocks-basic'
import styleFilterPlugin from 'grapesjs-style-filter'
import formPlugin from 'grapesjs-plugin-forms'
// import tailwindPlugin from 'grapesjs-tailwind'
import codePlugin from 'grapesjs-custom-code'
import filterStyles from '@silexlabs/grapesjs-filter-styles'
import symbolsPlugin from '@silexlabs/grapesjs-symbols'
import loadingPlugin from '@silexlabs/grapesjs-loading'
import fontsDialogPlugin from '@silexlabs/grapesjs-fonts'
import cssVariablesPlugin from '@silexlabs/grapesjs-css-variables'
import selectorPlugin from '@silexlabs/grapesjs-advanced-selector'
import symbolDialogsPlugin, { cmdPromptAddSymbol } from './symbolDialogs'
import loginDialogPlugin, { LoginDialogOptions, cmdLogout } from './LoginDialog'
import footerPlugin from './footer'
import breadcrumbsPlugin from './breadcrumbs'
import imgPlugin from './img'
import liPlugin from './li'
import flexPlugin from './flex'
import cssPropsPlugin from './css-props'
import rateLimitPlugin from '@silexlabs/grapesjs-storage-rate-limit'
import borderPugin from 'grapesjs-style-border'
import backgroundPlugin from 'grapesjs-style-bg'
import resizePanelPlugin from './resize-panel'
import notificationsPlugin, { NOTIFICATION_CHANGED } from '@silexlabs/grapesjs-notifications'
import keymapsDialogPlugin, { cmdKeymapsDialog } from '@silexlabs/grapesjs-keymaps-dialog'
import parserPostCSS from 'grapesjs-parser-postcss'
import { pagePanelPlugin, cmdTogglePages, cmdAddPage } from './page-panel'
import { newPageDialog, cmdOpenNewPageDialog } from './new-page-dialog'
import { PROJECT_BAR_PANEL_ID, projectBarPlugin } from './project-bar'
import { settingsDialog, cmdOpenSettings } from './settings'
import { blocksPlugin } from './blocks'
import { semanticPlugin } from './semantic'
import { orderedList, richTextPlugin, unorderedList } from './rich-text'
import { internalLinksPlugin } from './internal-links'
import {defaultKms, keymapsPlugin} from './keymaps'
import publicationManagerPlugin, { PublicationManagerOptions } from './PublicationManager'
import aiCapabilitiesPlugin from '@silexlabs/grapesjs-ai-capabilities'
import coreCommandsPlugin from './core-commands'
import ViewButtons from './view-buttons'
import { storagePlugin } from './storage'
import { API_PATH, API_WEBSITE_ASSETS_WRITE, API_WEBSITE_PATH, SILEX_VERSION } from '../../constants'
import { ClientConfig } from '../config'
import { titleCase } from '../utils'
import uploadProgress from './upload-progress'
import cmsPlugin from './cms'
import { ClientEvent } from '../events'
import { ClientSideFileWithContent, PublicationData } from '../../types'
const plugins = [
{name: './project-bar', value: projectBarPlugin}, // has to be before panels and dialogs
{name: 'grapesjs-style-bg', value: backgroundPlugin},
{name: './settings', value: settingsDialog},
{name: '@silexlabs/grapesjs-fonts', value: fontsDialogPlugin},
{name: '@silexlabs/grapesjs-css-variables', value: cssVariablesPlugin},
{name: '@silexlabs/grapesjs-advanced-selector', value: selectorPlugin},
{name: './new-page-dialog', value: newPageDialog},
{name: './page-panel', value: pagePanelPlugin},
{name: 'grapesjs-blocks-basic', value: blocksBasicPlugin},
{name: './blocks', value: blocksPlugin},
{name: './view-buttons', value: ViewButtons},
{name: './semantic', value: semanticPlugin},
{name: './rich-text', value: richTextPlugin},
{name: 'grapesjs-style-filter', value: styleFilterPlugin},
{name: 'grapesjs-plugin-forms', value: formPlugin},
// {name: 'grapesjs-tailwind', value: tailwindPlugin},
{name: 'grapesjs-custom-code', value: codePlugin},
{name: './internal-links', value: internalLinksPlugin},
{name: './keymaps', value: keymapsPlugin},
{name: '@silexlabs/grapesjs-filter-styles', value: filterStyles},
{name: './symbolDialogs', value: symbolDialogsPlugin},
{name: '@silexlabs/grapesjs-symbols', value: symbolsPlugin},
{name: './PublicationManager', value: publicationManagerPlugin},
{name: './storage', value: storagePlugin},
{name: './LoginDialog', value: loginDialogPlugin},
{name: '@silexlabs/grapesjs-loading', value: loadingPlugin},
{name: './breadcrumbs', value: breadcrumbsPlugin},
{name: './img', value: imgPlugin},
{name: './li', value: liPlugin},
{name: './flex', value: flexPlugin},
{name: './css-props', value: cssPropsPlugin},
{name: './footer', value: footerPlugin},
{name: '@silexlabs/grapesjs-storage-rate-limit', value: rateLimitPlugin},
{name: 'grapesjs-style-border', value: borderPugin},
{name: './resize-panel', value: resizePanelPlugin},
{name: '@silexlabs/grapesjs-notifications', value: notificationsPlugin},
{name: '@silexlabs/grapesjs-keymaps-dialog', value: keymapsDialogPlugin},
{name: 'grapesjs-parser-postcss', value: parserPostCSS},
{name: './upload-progress', value: uploadProgress},
{name: '@silexlabs/grapesjs-ai-capabilities', value: aiCapabilitiesPlugin},
{name: './core-commands', value: coreCommandsPlugin},
]
// Check that all plugins are loaded correctly
plugins
.filter(p => typeof p.value !== 'function')
.forEach(p => {
throw new Error(`Plugin ${p.name} could not be loaded correctly (${p.value})`)
})
// Constants
const PRIMARY_COLOR = '#333333'
const SECONDARY_COLOR = '#ddd'
const TERTIARY_COLOR = '#8873FE'
const QUATERNARY_COLOR = '#A291FF'
const DARKER_PRIMARY_COLOR = '#363636'
const LIGHTER_PRIMARY_COLOR = '#575757'
// Commands
export const cmdToggleLayers = 'open-layers'
export const cmdToggleBlocks = 'open-blocks'
export const cmdToggleSymbols = 'open-symbols'
export const cmdToggleNotifications = 'open-notifications'
// ////////////////////
// Config
// ////////////////////
const catBasic = 'Containers'
const catComponents = 'Components'
export function getEditorConfig(config: ClientConfig): EditorConfig {
const { websiteId, storageId, rootUrl } = config
// Create dynamic plugins array with conditional CMS plugin
const dynamicPlugins = [...plugins]
const dynamicPluginsOpts: any = {}
// Add CMS plugin if enabled
if (config.cmsConfig?.enabled !== false) {
dynamicPlugins.push({name: './cms', value: cmsPlugin})
dynamicPluginsOpts[cmsPlugin.toString()] = config.cmsConfig
}
return {
container: '#gjs',
height: '100%',
showOffsets: true,
showDevices: true,
//pageManager: {},
telemetry: false,
layerManager: {
appendTo: '.layer-manager-container',
},
blockManager: {
appendTo: '.block-manager-container',
},
assetManager: {
upload: `${rootUrl}${API_PATH}${API_WEBSITE_PATH}${API_WEBSITE_ASSETS_WRITE}?websiteId=${websiteId}${ storageId ? `&connectorId=${storageId}` : ''}`,
},
storageManager: {
autoload: false,
type: 'connector',
options: {
connector: {
id: websiteId,
connectorId: storageId,
// If "progressive", it will load the site pages one by one for a better UX (not blocking the thread), but it breaks grapesjs symbols
// Check https://github.com/GrapesJS/grapesjs/issues/6663
mode: '',
},
},
},
cssIcons: `./css/all.min.css?${SILEX_VERSION}`,
richTextEditor: {
// @ts-ignore
actions: ['bold', 'italic', 'underline', 'strikethrough', 'link', 'wrap', orderedList, unorderedList],
},
selectorManager: {
custom: true, // This should not be needed, check index.js
escapeName: (name) => `${name}`,
},
plugins: dynamicPlugins.map(p => p.value),
pluginsOpts: {
...dynamicPluginsOpts,
[blocksBasicPlugin.toString()]: {
blocks: ['text', 'image', 'video', 'map'],
category: catBasic,
//flexGrid: true,
},
[projectBarPlugin.toString()]: {
panels: [
{
id: 'dash',
className: 'logo',
attributes: { title: 'Dashboard' },
command: () => {
window.location.href = '/'
},
}, {
id: 'block-manager-btn',
className: 'block-manager-btn fa fa-fw fa-plus',
name: 'Blocks',
attributes: { title: `Blocks (${titleCase(defaultKms.kmBlocks.keys, '+')})`, containerClassName: 'block-manager-container', },
command: cmdToggleBlocks,
}, {
id: 'symbols-btn',
className: 'symbols-btn fa-regular fa-gem',
name: 'Symbols',
attributes: { title: `Symbols (${titleCase(defaultKms.kmSymbols.keys, '+')})`, containerClassName: 'symbols-list-container', },
command: cmdToggleSymbols,
buttons: [
{
id: 'symbol-create-button',
className: 'gjs-pn-btn',
command: cmdPromptAddSymbol,
text: '\u271A',
},
],
}, {
id: 'page-panel-btn',
className: 'page-panel-btn fa fa-fw fa-file',
name: 'Pages',
attributes: { title: `Pages (${titleCase(defaultKms.kmPages.keys, '+')})`, containerClassName: 'page-panel-container', },
command: cmdTogglePages,
buttons: [{
className: 'gjs-pn-btn',
command: cmdAddPage,
text: '\u271A',
}],
}, {
id: 'layer-manager-btn',
className: 'layer-manager-btn fa-solid fa-layer-group',
name: 'Layers',
attributes: { title: `Layers (${titleCase(defaultKms.kmLayers.keys, '+')})`, containerClassName: 'layer-manager-container', },
command: cmdToggleLayers,
}, {
id: 'font-dialog-btn',
className: 'font-manager-btn fa-solid fa-font',
name: 'Fonts',
attributes: { title: `Fonts (${titleCase(defaultKms.kmOpenFonts.keys, '+')})` },
command: () => {
editor.runCommand('open-fonts')
},
}, {
id: 'css-variables-btn',
className: 'css-variables-btn fa-solid fa-paintbrush',
name: 'Variables',
attributes: { title: 'CSS Variables' },
command: () => {
editor.runCommand('open-css-variables')
},
}, {
id: 'settings-dialog-btn',
className: 'page-panel-btn fa-solid fa-gears',
name: 'Settings',
attributes: { title: `Settings (${titleCase(defaultKms.kmOpenSettings.keys, '+')})` },
command: cmdOpenSettings,
//}, {
// id: 'tailwind-theme',
// className: 'fa-solid fa-brush',
// attributes: { title: 'Tailwind color theme' },
// command: 'open-update-theme'
}, {
id: 'spacer',
attributes: {},
className: 'project-bar-spacer',
}, {
id: 'keymaps-btn',
className: 'keymaps-btn fa-solid fa-keyboard',
name: 'Shortcuts',
attributes: { title: 'Keyboard Shortcuts (Shift+H)' },
command: cmdKeymapsDialog,
}, {
id: 'notifications-btn',
className: 'notifications-btn fa-regular fa-bell',
name: 'Notifications',
attributes: { title: `Notifications (${titleCase(defaultKms.kmNotifications.keys, '+')})`, containerClassName: 'notifications-container', },
command: cmdToggleNotifications,
buttons: [{
className: 'gjs-pn-btn',
command: 'notifications:clear',
text: '\u2716',
}],
}, {
id: 'dash2',
className: 'fa-solid fa-house',
attributes: { title: 'Dashboard' },
command: () => {
window.location.href = '/'
},
}, {
id: 'help',
className: 'fa fa-fw fa-question-circle',
attributes: { title: 'Documentation' },
command: () => {
window.open('https://docs.silex.me/', '_blank')
},
}, {
id: 'logout-button',
className: 'page-panel-btn fa fa-fw fa-sign-out',
attributes: { title: 'Sign out' },
command: cmdLogout,
},
],
},
[publicationManagerPlugin.toString()]: {
appendTo: 'options',
websiteId,
} as PublicationManagerOptions,
[pagePanelPlugin.toString()]: {
cmdOpenNewPageDialog,
cmdOpenSettings,
appendTo: '.page-panel-container',
},
[filterStyles.toString()]: {
appendBefore: '.gjs-sm-sectors',
},
[internalLinksPlugin.toString()]: {
// FIXME: warn the user about links in error
onError: (errors) => console.warn('Links errors:', errors),
},
[keymapsPlugin.toString()]: {
disableKeymaps: false,
},
// [tailwindPlugin.toString()]: {
// tailwindPlayCdn: `${config.rootUrl}/tailwind-3.4.17.js`,
// },
[codePlugin.toString()]: {
blockLabel: 'HTML',
blockCustomCode: {
category: catComponents,
},
codeViewOptions: {
autoFormat: false,
},
},
[symbolsPlugin.toString()]: {
appendTo: '.symbols-list-container',
emptyText: 'No symbol yet.',
primaryColor: PRIMARY_COLOR,
secondaryColor: SECONDARY_COLOR,
highlightColor: TERTIARY_COLOR,
},
[fontsDialogPlugin.toString()]: {
api_key: config.fontsApiKey,
server_url: config.fontsServerUrl,
api_url: config.fontsApiUrl,
},
[cssVariablesPlugin.toString()]: {
prefix: '',
},
[loginDialogPlugin.toString()]: {
id: websiteId,
} as LoginDialogOptions,
[rateLimitPlugin.toString()]: {
time: 5000,
},
[imgPlugin.toString()]: {
replacedElements: config.replacedElements,
websiteId,
storageId,
},
[notificationsPlugin.toString()]: {
container: notificationContainer,
reverse: true,
},
[keymapsDialogPlugin.toString()]: {
longPressDuration: null,
shortcut: 'shift+h',
},
},
}
}
// ////////////////////
// Cross-iframe drag-and-drop workaround
// ////////////////////
/**
* Detect environments where HTML5 native drag-and-drop across iframes
* doesn't work: non-Chrome WebKit (WebKitGTK, WKWebView) and Tauri
* desktop (WebView2 on Windows also fails cross-iframe drag).
*/
function needsDragPatch(): boolean {
const ua = navigator.userAgent
const isNonChromeWebKit = /AppleWebKit/.test(ua) && !/Chrome/.test(ua)
const isTauri = !!(window as any).__TAURI_INTERNALS__
return isNonChromeWebKit || isTauri
}
/**
* Patch GrapesJS blocks for environments that don't support HTML5 native
* drag-and-drop across iframes (WebKitGTK, WKWebView, WebView2 in Tauri).
* Disables the draggable attribute so GrapesJS falls back to its
* mousedown-based sorter path, and prevents text selection during drag.
*/
function patchBlocksDragDrop(editor: Editor) {
const bm = editor.BlockManager
const container = document.querySelector(
(bm.getConfig() as any).appendTo as string
) as HTMLElement | null
if (!container) return
// Disable HTML5 native drag on all block elements.
// GrapesJS's BlockView already has a mousedown handler (startDrag) that
// uses the sorter when el.draggable is false — this is the correct
// fallback path for browsers without cross-iframe drag-and-drop.
function disableDraggable() {
container!.querySelectorAll('.gjs-block[draggable="true"]').forEach(el => {
el.setAttribute('draggable', 'false')
})
}
disableDraggable()
new MutationObserver(disableDraggable)
.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ['draggable'] })
// Prevent text selection while dragging blocks
container.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button !== 0) return
if (!(e.target as HTMLElement).closest('.gjs-block')) return
const preventSelect = (ev: Event) => ev.preventDefault()
document.addEventListener('selectstart', preventSelect)
const cleanup = () => {
document.removeEventListener('selectstart', preventSelect)
document.removeEventListener('mouseup', cleanup)
}
document.addEventListener('mouseup', cleanup)
})
}
// ////////////////////
// Initialize editor
// ////////////////////
// Keep a ref to the editor singleton
let editor: Editor
export async function initEditor(config: EditorConfig) {
if(editor) throw new Error('Grapesjs editor already created')
return new Promise<Editor>((resolve, reject) => {
try {
/* @ts-ignore */
editor = grapesjs.init(config)
} catch(e) {
console.error('Error initializing GrapesJs with plugins:', plugins, e)
reject(e)
}
// customize the editor
['text']
.forEach(id => editor.Blocks.get(id)?.set('category', 'Basics'))
;['image', 'video']
.forEach(id => editor.Blocks.get(id)?.set('category', 'Media'))
;['map']
.forEach(id => editor.Blocks.get(id)?.set('category', 'Components'))
editor.Blocks.render([])
editor.Commands.add('gjs-open-import-webpage', openImport(editor, {
modalImportLabel: '',
modalImportContent: 'Paste a web page HTML code here. This is an experiment, it might destroy the existing website.',
modalImportButton: 'Import',
modalImportTitle: 'Import from website',
}))
// // Add tailwind css to the published site
// editor.on(ClientEvent.PUBLISH_DATA, ({ data }: { data: PublicationData }) => {
// editor.runCommand('get-tailwindCss', {
// callback: (css: string) => (data.files.find(f => f.type === 'css') as ClientSideFileWithContent).content += css,
// })
// })
// Detect loading errors
// Display a useful notification
const typeConfig = {
view: {
onRender({editor, el, model}) {
const src = model.getAttributes().src
el.addEventListener('error', () => {
editor.runCommand('notifications:add', {
type: 'error',
group: 'Image loading error',
message: `Error loading image: ${src}`,
componentId: model.getId(),
})
})
},
},
}
const dc = editor.DomComponents
dc.addType('image', typeConfig)
dc.addType('iframe', typeConfig)
dc.getTypes().map(type => {
const dcmp = dc.getType(type.id)?.model.prototype
dc.addType(type.id, {
model: {
defaults: {
traits: [
{
type: 'text',
label: 'ID',
name: 'id',
},
...(dcmp.defaults.traits || []),
]
},
},
})
})
// Adjustments to do when the editor is ready
editor.on('load', () => {
const views = editor.Panels.getPanel('views')
// Remove blocks and layers buttons from the properties
// This is because in Silex they are on the left
views.buttons.remove(cmdToggleBlocks)
views.buttons.remove(cmdToggleLayers)
// Select body when changing pages
// This includes initial loading, as the 1st page is selected in storage
editor.on('page:select', () => {
// Only select wrapper if nothing else is selected
if (!editor.getSelected()) {
const wrapper = editor.getWrapper()
if (wrapper) {
editor.select(wrapper)
}
}
})
// Remove useless buttons
editor.Panels.getPanel('options').buttons.remove('export-template')
editor.Panels.getPanel('options').buttons.remove('fullscreen')
// Render the block manager, otherwise it is empty
editor.BlockManager.render(null)
// Some environments (WebKitGTK, WKWebView, Tauri WebView2) don't support
// HTML5 native drag-and-drop across iframes. Patch blocks to use pointer events instead.
if (needsDragPatch()) {
patchBlocksDragDrop(editor)
}
// Use the style filter plugin
editor.StyleManager.addProperty('extra', { extend: 'filter' })
// Add the notifications container
document.body.querySelector('.notifications-container')?.appendChild(notificationContainer)
// Mark the button as dirty when there are notifications
let notificationCount = 0
editor.on(NOTIFICATION_CHANGED, (notifications) => {
const notificationButton = editor.Panels.getPanel(PROJECT_BAR_PANEL_ID).view?.el.querySelector('.notifications-btn')
notificationCount = Array.isArray(notifications) ? notifications.length : 0
notificationCount > 0
? notificationButton?.classList.add('project-bar__dirty')
: notificationButton?.classList.remove('project-bar__dirty')
})
// GrapesJs editor is ready
resolve(editor)
})
})
}
export function getEditor() {
return editor
}