@silexlabs/grapesjs-fonts
Version:
354 lines (322 loc) • 11.6 kB
JavaScript
import {html, render} from 'lit-html'
import {live} from 'lit-html/directives/live.js'
import { map } from 'lit-html/directives/map.js'
import {styleMap} from 'lit-html/directives/style-map.js'
import {ref, createRef} from 'lit-html/directives/ref.js'
const el = document.createElement('div')
let modal
export const cmdOpenFonts = 'open-fonts'
/**
* Constants
*/
const LS_FONTS = 'silex-loaded-fonts-list'
/**
* Module variables
*/
let _fontsList
let fonts
let defaults = []
/**
* Options
*/
let fontServer = 'https://fonts.googleapis.com'
let fontApi = 'https://www.googleapis.com'
/**
* Load available fonts only once per session
* Use local storage
*/
try {
_fontsList = JSON.parse(localStorage.getItem(LS_FONTS))
} catch(e) {
console.error('Could not get fonts from local storage:', e)
}
/**
* Promised wait function
*/
async function wait(ms = 0) {
return new Promise(resolve => setTimeout(() => resolve(), ms))
}
/**
* When the dialog is opened
*/
async function loadFonts(editor) {
fonts = structuredClone(editor.getModel().get('fonts') || [])
}
/**
* When the dialog is closed
*/
function saveFonts(editor, opts) {
const model = editor.getModel()
// Store the modified fonts
model.set('fonts', fonts)
// Update the HTML head with style sheets to load
updateHead(editor, fonts)
// Update the "font family" dropdown
updateUi(editor, fonts, opts)
// Save website if auto save is on
model.set('changesCount', editor.getDirtyCount() + 1)
}
/**
* Load the available fonts from google
*/
async function loadFontList(url) {
_fontsList = _fontsList ?? (await (await fetch(url)).json())?.items
localStorage.setItem(LS_FONTS, JSON.stringify(_fontsList))
await wait() // let the dialog open
return _fontsList
}
export const fontsDialogPlugin = (editor, opts) => {
defaults = editor.StyleManager.getBuiltIn('font-family').options
if(opts.server_url) fontServer = opts.server_url
if(opts.api_url) fontApi = opts.api_url
if(!opts.api_key) throw new Error(editor.I18n.t('grapesjs-fonts.You must provide Google font api key'))
editor.Commands.add(cmdOpenFonts, {
/* eslint-disable-next-line */
run: (_, sender) => {
modal = editor.Modal.open({
title: editor.I18n.t('grapesjs-fonts.Fonts'),
content: '',
attributes: { class: 'fonts-dialog' },
})
.onceClose(() => {
editor.stopCommand(cmdOpenFonts) // apparently this is needed to be able to run the command several times
})
modal.setContent(el)
loadFonts(editor)
displayFonts(editor, opts, [])
loadFontList(`${ fontApi }/webfonts/v1/webfonts?key=${ opts.api_key }`)
.then(fontsList => { // the run command will terminate before this is done, better for performance
displayFonts(editor, opts, fontsList)
const form = el.querySelector('form')
form.onsubmit = event => {
event.preventDefault()
saveFonts(editor, opts)
editor.stopCommand(cmdOpenFonts)
}
form.querySelector('input')?.focus()
})
return modal
},
stop: () => {
modal.close()
},
})
// add fonts to the website on save
editor.on('storage:start:store', (data) => {
data.fonts = editor.getModel().get('fonts')
})
// helper to register debounced font refreshes
let debouncedRefreshTimeout
const debouncedRefresh = () => {
clearTimeout(debouncedRefreshTimeout)
debouncedRefreshTimeout = setTimeout(() => refresh(editor, opts), 50)
}
// add fonts to the website on load
editor.on('storage:end:load', (data) => {
const fonts = data.fonts || []
editor.getModel().set('fonts', fonts)
// FIXME: remove this timeout which is a workaround for issues in Silex storage providers
debouncedRefresh()
})
// update the head and the ui when the frame is loaded
editor.on('canvas:frame:load', () => {
debouncedRefresh()
})
// When the page changes, update the dom
editor.on('page', () => {
// FIXME: remove this timeout which is a workaround for issues with fonts loading after page change
debouncedRefresh()
})
}
function match(hay, s) {
const regExp = new RegExp(s, 'i')
return hay.search(regExp) !== -1
}
const searchInputRef = createRef()
const fontRef = createRef()
function displayFonts(editor, config, fontsList) {
const searchInput = searchInputRef.value
const activeFonts = fontsList.filter(f => match(f.family, searchInput?.value || ''))
searchInput?.focus()
function findFont(font) {
return fontsList.find(f => font.name === f.family)
}
render(html`
<form class="silex-form grapesjs-fonts">
<div class="silex-form__group">
<div class="silex-bar">
<input
style=${styleMap({
width: '100%',
})}
placeholder="${editor.I18n.t('grapesjs-fonts.Search')}"
type="text"
${ref(searchInputRef)}
@keydown=${() => {
//(fontRef.value as HTMLSelectElement).selectedIndex = 0
setTimeout(() => displayFonts(editor, config, fontsList))
}}/>
<select
style=${styleMap({
width: '150px',
})}
${ref(fontRef)}
>
${ map(activeFonts, f => html`
<option value=${f['family']}>${f['family']}</option>
`)}
</select>
<button class="silex-button"
?disabled=${!fontRef.value || activeFonts.length === 0}
type="button" @click=${() => {
addFont(
editor,
config,
fonts,
activeFonts[fontRef.value.selectedIndex]
)
displayFonts(editor, config, fontsList)
}}>
${editor.I18n.t('grapesjs-fonts.Add font')}
</button>
</div>
</div>
<hr/>
<div
class="silex-form__element">
<h2>${editor.I18n.t('grapesjs-fonts.Installed fonts')}</h2>
<ol class="silex-list">
${ map(fonts, f => html`
<li>
<div class="silex-list__item__header">
<h4>${f.name}</h4>
</div>
<div class="silex-list__item__body">
<fieldset class="silex-group--simple full-width">
<legend>CSS rules</legend>
<input
class="full-width"
type="text"
name="name"
.value=${live(f.value)}
@change=${e => {
updateRules(editor, fonts, f, e.target.value)
displayFonts(editor, config, fontsList)
}}
/>
</fieldset>
<fieldset class="silex-group--simple full-width">
<legend>Variants</legend>
${ map(
// keep only variants which are letters, no numbers
// FIXME: we need the weights
findFont(f)?.variants.filter(v => v.replace(/[a-z]/g, '') === ''),
v => html`
<div>
<input
id=${ f.name + v }
type="checkbox"
value=${v}
?checked=${f.variants?.includes(v)}
@change=${e => {
updateVariant(editor, fonts, f, v, e.target.checked)
displayFonts(editor, config, fontsList)
}}
/><label for=${ f.name + v }>${v}</label>
</div>
`)}
</fieldset>
</div>
<div class="silex-list__item__footer">
<button class="silex-button" type="button" @click=${() => {
removeFont(editor, fonts, f)
displayFonts(editor, config, fontsList)
}}>${editor.I18n.t('grapesjs-fonts.Remove')}</button>
</div>
</li>
`) }
</ol>
</div>
<footer>
<input class="silex-button" type="button" @click=${() => editor.stopCommand(cmdOpenFonts)} value="${editor.I18n.t('grapesjs-fonts.Cancel')}">
<input class="silex-button" type="submit" value="${editor.I18n.t('grapesjs-fonts.Save')}">
</footer>
</form>
`, el)
}
function addFont(editor, config, fonts, font) {
const name = font.family
const value = `"${font.family}", ${font.category}`
fonts.push({ name, value, variants: [] })
}
function removeFont(editor, fonts, font) {
const idx = fonts.findIndex(f => f === font)
fonts.splice(idx, 1)
}
function removeAll(doc, attr) {
const all = doc.head.querySelectorAll(`[${ attr }]`)
Array.from(all)
.forEach((el) => el.remove())
}
const GOOGLE_FONTS_ATTR = 'data-silex-gstatic'
function updateHead(editor, fonts) {
const doc = editor.Canvas.getDocument()
if(!doc) {
// This happens while grapesjs is not ready
return
}
removeAll(doc, GOOGLE_FONTS_ATTR)
const html = getHtml(fonts, GOOGLE_FONTS_ATTR)
doc.head.insertAdjacentHTML('beforeend', html)
}
function updateUi(editor, fonts, opts) {
const styleManager = editor.StyleManager
const fontProperty = styleManager.getProperty('typography', 'font-family')
if(!fontProperty) {
// This happens while grapesjs is not ready
return
}
if (opts.preserveDefaultFonts) {
fonts = defaults.concat(fonts)
} else if (fonts.length === 0) {
fonts = defaults
}
fontProperty.setOptions(fonts)
}
export function refresh(editor, opts) {
const fonts = editor.getModel().get('fonts') || []
updateHead(editor, fonts)
updateUi(editor, fonts, opts)
}
function updateRules(editor, fonts, font, value) {
font.value = value
}
function updateVariant(editor, fonts, font, variant, checked) {
const has = font.variants?.includes(variant)
if(has && !checked) font.variants = font.variants.filter(v => v !== variant)
else if(!has && checked) font.variants.push(variant)
}
export function getHtml(fonts, attr = '') {
// FIXME: how to use google fonts v2?
// google fonts V2: https://developers.google.com/fonts/docs/css2
//fonts.forEach(f => {
// const prefix = f.variants.length ? ':' : ''
// const variants = prefix + f.variants.map(v => {
// const weight = parseInt(v)
// const axis = v.replace(/\d+/g, '')
// return `${axis},wght@${weight}`
// }).join(',')
// insert(doc, GOOGLE_FONTS_ATTR, 'link', { 'href': `${ fontServer }/css2?family=${f.name.replace(/ /g, '+')}${variants}&display=swap`, 'rel': 'stylesheet' })
//})
// Google fonts v1
// https://developers.google.com/fonts/docs/getting_started#a_quick_example
const preconnect = fonts.length ? `<link href="${ fontServer }" rel="preconnect" ${attr}><link href="https://fonts.gstatic.com" rel="preconnect" crossorigin ${attr}>` : ''
const links = fonts
.map(f => {
const prefix = f.variants.length ? ':' : ''
const variants = prefix + f.variants.map(v => v.replace(/\d+/g, '')).filter(v => !!v).join(',')
return `<link href="${ fontServer }/css?family=${f.name.replace(/ /g, '+')}${variants}&display=swap" rel="stylesheet" ${attr}>`
})
.join('')
return preconnect + links
}