@silexlabs/grapesjs-fonts
Version:
397 lines (353 loc) • 15.6 kB
JavaScript
import { html, render } from 'lit-html'
import { map } from 'lit-html/directives/map.js'
import {ref, createRef} from 'lit-html/directives/ref.js'
const el = document.createElement('div')
const styles = document.createElement('style')
styles.textContent = `
.gfonts-section { padding: 8px 0 }
.gfonts-list { list-style: none; padding: 0; margin: 0 }
.gfonts-item { padding: 10px 12px; border: 1px solid var(--gjs-light-border); border-radius: 8px; margin-bottom: 10px; background: transparent; color: var(--gjs-font-color) }
.gfonts-item strong { display: block; font-size: 14px; margin-bottom: 4px; color: var(--gjs-font-color-active) }
.gfonts-item p { margin: 0 0 8px 0; color: var(--gjs-font-color); font-size: 12px }
.variants-toolbar { display: flex; align-items: center; gap: 8px; margin: 6px 0 }
.variants-grid { display: flex; flex-wrap: wrap; gap: 6px }
.variants-grid label { background: var(--gjs-soft-light-color); border-radius: 4px; padding: 4px 6px; cursor: pointer; border: 1px solid transparent; color: var(--gjs-font-color) }
.variants-grid input { margin: 0 }
.gfonts-preview { padding: 6px; border: 1px solid var(--gjs-light-border); border-radius: 8px; background: var(--gjs-soft-light-color); margin: 6px 0 8px }
.preview-iframe { width: 100%; border: 0; height: 56px; background: transparent }
.silex-bar, .pagination-controls { display: flex; gap: 8px; align-items: center }
.silex-list { list-style: none; margin: 0; padding: 0 }
.silex-list > li { list-style: none }
fieldset.silex-group--simple { border: 0; padding: 0; margin: 0 }
fieldset.silex-group--simple legend { margin: 0; color: var(--gjs-font-color); font-size: 12px }
.gfonts-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap }
.gfonts-toolbar select, .gfonts-toolbar input { background: var(--gjs-main-light-color); color: var(--gjs-font-color); border: 1px solid var(--gjs-light-border); border-radius: 4px; padding: 4px 6px }
.gfonts-toolbar .silex-button { background: var(--gjs-main-light-color); color: var(--gjs-font-color); border: 1px solid var(--gjs-light-border) }
.gfonts-pager { margin-left: auto; display: inline-flex; gap: 6px; align-items: center; color: var(--gjs-font-color) }
.gfonts-pager .page { opacity: .8 }
.gfonts-actions { display: flex; gap: 8px; margin-top: 6px }
.gfonts-btn--ghost { background: transparent; border: 1px solid var(--gjs-light-border); color: var(--gjs-font-color); padding: 2px 6px; font-size: 11px }
.gfonts-btn--danger { background: transparent; border: 1px solid var(--gjs-color-red); color: var(--gjs-color-red) }
`
document.head.appendChild(styles)
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') || []).map(f => ({
...f,
variants: f.variants || []
}))
}
/**
* When the dialog is closed
*/
function saveFonts(editor, opts) {
const model = editor.getModel()
model.set('fonts', fonts)
updateHead(editor, fonts)
updateUi(editor, fonts, opts)
}
/**
* Get the cached list of available fonts from Google
*/
export function getAvailableFonts() {
return _fontsList
}
/**
* Get the configured Google Fonts API base URL
*/
export function getApiUrl() {
return fontApi
}
/**
* Load the available fonts from google
*/
export 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, {
run: () => {
modal = editor.Modal.open({
title: editor.I18n.t('grapesjs-fonts.Fonts'),
content: '',
attributes: { class: 'fonts-dialog' },
})
.onceClose(() => {
editor.stopCommand(cmdOpenFonts)
})
modal.setContent(el)
loadFonts(editor)
displayFontLists(editor, opts)
loadFontList(`${fontApi}/webfonts/v1/webfonts?key=${opts.api_key}`)
.then(fontsList => {
displayFontLists(editor, opts, fontsList)
})
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)
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', () => {
debouncedRefresh()
})
}
const searchInputRef = createRef()
let currentPage = 0
let selectedCategory = ''
function displayFontLists(editor, opts, originalFontsList = []) {
const searchInput = searchInputRef.value
const fontsPerPage = 10
let fontsList = JSON.parse(JSON.stringify(originalFontsList)) // deep copy to maintain original list
const activeFonts = fontsList.filter(f => {
const nameOk = match(f.family, searchInput?.value || '')
const catOk = selectedCategory ? f.category === selectedCategory : true
return nameOk && catOk
})
let pageCount = Math.ceil(activeFonts.length / fontsPerPage)
const cats = Array.from(new Set((originalFontsList || []).map(f => f.category).filter(Boolean))).sort()
// Adjust currentPage if it exceeds the available pages
if (currentPage >= pageCount) {
currentPage = Math.max(0, pageCount - 1)
}
const currentFonts = activeFonts.slice(currentPage * fontsPerPage, (currentPage + 1) * fontsPerPage)
render(html`
<div>
<div class="silex-form__element">
<h2>${editor.I18n.t('grapesjs-fonts.Installed fonts')}</h2>
<ul class="silex-list">
${fonts && fonts.length ? map(fonts, font => html`
<li class="gfonts-item">
<strong>${font.family || 'Unknown Font'}</strong>
<p>${(font.category || ((_fontsList || []).find(f => f.family === font.family)?.category)) || 'No category'} - ${font.variants?.length || 0} variants</p>
<div class="gfonts-preview">
${generateFontPreview(font)}
</div>
<fieldset class="silex-group--simple full-width">
<div class="variants-toolbar">
<legend>${editor.I18n.t('grapesjs-fonts.Variants') || 'Variants'}</legend>
<button class="silex-button gfonts-btn--ghost" type="button" @click=${() => {
const all = (((_fontsList || []).find(f => f.family === font.family)?.variants) || [])
const isAll = Array.isArray(font.variants) && all.length && all.every(v => font.variants.includes(v))
font.variants = isAll ? [] : [...all]
saveFonts(editor, opts)
displayFontLists(editor, opts, originalFontsList)
}}>${editor.I18n.t('grapesjs-fonts.Toggle all') || 'Toggle all'}</button>
</div>
<div class="variants-grid">
${map(((_fontsList || []).find(f => f.family === font.family)?.variants) || [], v => html`
<label>
<input
type="checkbox"
.checked=${(font.variants || []).includes(v)}
@change=${e => {
const checked = e.target.checked
font.variants = Array.isArray(font.variants) ? font.variants : []
const has = font.variants.includes(v)
if (checked && !has) font.variants = [...font.variants, v]
if (!checked && has) font.variants = font.variants.filter(x => x !== v)
saveFonts(editor, opts)
displayFontLists(editor, opts, originalFontsList)
}}
/>
${v}
</label>
`)}
</div>
</fieldset>
<div class="gfonts-actions">
<button class="silex-button gfonts-btn--danger" type="button" @click=${() => { fonts = fonts.filter(x => x.family !== font.family); saveFonts(editor, opts); displayFontLists(editor, opts, originalFontsList) }}>${editor.I18n.t('grapesjs-fonts.Remove')}</button>
</div>
</li>
`) : html`<li class="gfonts-item" style="border-style:dashed; text-align:center; font-style:italic; color: var(--gjs-secondary-light-color)">${editor.I18n.t('grapesjs-fonts.No installed fonts') || 'No installed fonts yet'}</li>`}
</ul>
</div>
<div>
<h2>${editor.I18n.t('grapesjs-fonts.All fonts') || 'All fonts'}</h2>
<div class="silex-bar gfonts-toolbar">
<select @change=${e => { selectedCategory = e.target.value || ''; currentPage = 0; displayFontLists(editor, opts, originalFontsList) }}>
<option value="">All categories</option>
${cats.map(c => html`<option value=${c} ?selected=${selectedCategory === c}>${c}</option>`)}
</select>
<input
type="text"
${ref(searchInputRef)}
@input=${() => {
currentPage = 0
displayFontLists(editor, opts, originalFontsList)
}}
placeholder="${editor.I18n.t('grapesjs-fonts.Search')}"
/>
<div class="gfonts-pager">
<button class="silex-button" ?disabled=${currentPage === 0} @click=${() => {
if (currentPage > 0) {
currentPage--
displayFontLists(editor, opts, originalFontsList)
}
}}>${editor.I18n.t('grapesjs-fonts.Previous Page')}</button>
<span class="page">Page ${currentPage + 1} / ${pageCount}</span>
<button class="silex-button" ?disabled=${currentPage === pageCount - 1} @click=${() => {
if (currentPage < pageCount - 1) {
currentPage++
displayFontLists(editor, opts, originalFontsList)
}
}}>${editor.I18n.t('grapesjs-fonts.Next Page')}</button>
</div>
</div>
<ul class="silex-list">
${map(currentFonts, f => html`
<li class="gfonts-item">
<strong>${f.family}</strong>
<p>${f.category} - ${f.variants.length} variants</p>
<div class="gfonts-preview">
${generateFontPreview(f)}
</div>
<button class="silex-button" @click=${() => installFont(editor, opts, f, fontsList)}>${editor.I18n.t('grapesjs-fonts.Add font')}</button>
</li>
`)}
</ul>
</div>
</div>
`, el)
}
function generateFontPreview(font) {
const family = font.family || font.name || ''
const famQ = family.replace(/ /g, '+')
const color = (getComputedStyle(document.documentElement).getPropertyValue('--gjs-font-color') || '').trim() || getComputedStyle(el).color || '#ddd'
const inList = (_fontsList || []).find(f => f.family === family)
const rawVariants = Array.isArray(font.variants) && font.variants.length ? font.variants : (inList?.variants || ['regular'])
const pairs = []
rawVariants.forEach(v => {
const italic = /italic/i.test(v) ? 1 : 0
const m = String(v).match(/\d+/)
const w = m ? parseInt(m[0], 10) : 400
const key = `${italic},${w}`
if (!pairs.includes(key)) pairs.push(key)
})
const axis = pairs.length ? `:ital,wght@${pairs.join(';')}` : ''
const lines = pairs.map(k => {
const [ital, wght] = k.split(',')
const fs = ital === '1' ? 'italic' : 'normal'
return `<p style="margin:0; line-height:1.5; font-style:${fs}; font-weight:${wght}">${family} — Sphinx of black quartz, judge my vow. 0123456789</p>`
}).join('')
return html`
<iframe
class="preview-iframe"
srcdoc="<html><head><link href='https://fonts.googleapis.com/css2?family=${famQ}${axis}&display=swap' rel='stylesheet'><style>body { margin: 0; color: ${color}; font-family: '${family}', sans-serif }</style></head><body>${lines}</body></html>"></iframe>
`
}
function installFont(editor, opts, font, fontsList) {
const existingFont = fonts.find(f => f.family === font.family)
if (!existingFont) {
fonts.push({
family: font.family,
name: font.family, // Ensure the font family name is set for both 'family' and 'name'
category: font.category,
variants: font.variants,
value: `"${font.family}", ${font.category}`
})
}
saveFonts(editor, opts)
displayFontLists(editor, opts, fontsList)
}
function match(hay, s) {
return hay.toLowerCase().includes(s.toLowerCase())
}
function removeAll(doc, attr) {
const all = doc.head.querySelectorAll(`[${attr}]`)
Array.from(all).forEach((el) => el.remove())
}
function updateHead(editor, fonts) {
const doc = editor.Canvas.getDocument()
if (!doc) {
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) return
let list = fonts
if (opts && opts.preserveDefaultFonts) {
list = defaults.concat(fonts || [])
} else if (!fonts || fonts.length === 0) {
list = defaults
}
fontProperty.setOptions(list)
}
const GOOGLE_FONTS_ATTR = 'data-silex-gstatic'
export function refresh(editor, opts) {
const fonts = editor.getModel().get('fonts') || []
updateHead(editor, fonts)
updateUi(editor, fonts, opts)
}
export function getHtml(fonts, attr = '') {
const preconnect = fonts.length ? `<link href="${fontServer}" rel="preconnect" ${attr}><link href="${fontServer}/css2" rel="preconnect" crossorigin ${attr}>` : ''
const links = fonts.map(f => `<link href="${fontServer}/css2?family=${f.name.replace(/ /g, '+')}&display=swap" rel="stylesheet" ${attr}>`).join('')
return preconnect + links
}