Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/fonts/Caveat-Bold.ttf
Binary file not shown.
Binary file added public/fonts/Caveat-Regular.ttf
Binary file not shown.
Binary file added public/fonts/EBGaramond-Bold.ttf
Binary file not shown.
Binary file added public/fonts/FiraCode-Bold.ttf
Binary file not shown.
Binary file added public/fonts/FiraCode-Regular.ttf
Binary file not shown.
Binary file added public/fonts/Montserrat-Regular.ttf
Binary file not shown.
Binary file added public/fonts/OpenSans-Bold.ttf
Binary file not shown.
Binary file added public/fonts/PermanentMarker-Regular.ttf
Binary file not shown.
Binary file added public/fonts/Roboto-Regular.ttf
Binary file not shown.
Binary file added public/fonts/SourceHanSerifCN-Bold.ttf
Binary file not shown.
Binary file added public/fonts/SourceHanSerifCN-Regular.ttf
Binary file not shown.
2 changes: 1 addition & 1 deletion src/components/DropdownOption.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const DropdownOption = ({

let choices = option.choices
if (typeof choices === "function") {
choices = choices()
choices = choices(data)
}

choices = Array.isArray(choices)
Expand Down
2 changes: 0 additions & 2 deletions src/features/app/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import MachineManager from "@/features/machines/MachineManager"
import LayerManager from "@/features/layers/LayerManager"
import PreviewStats from "@/features/preview/PreviewStats"
import { selectSelectedLayer } from "@/features/layers/layersSlice"
import { loadFont, supportedFonts } from "@/features/fonts/fontsSlice"
import { loadImage, selectAllImages } from "@/features/images/imagesSlice"

const Sidebar = () => {
Expand All @@ -15,7 +14,6 @@ const Sidebar = () => {
const images = useSelector(selectAllImages)

useEffect(() => {
Object.keys(supportedFonts).forEach((url) => dispatch(loadFont(url)))
images.forEach((image) =>
dispatch(loadImage({ imageId: image.id, imageSrc: image.src })),
)
Expand Down
52 changes: 44 additions & 8 deletions src/features/app/store.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,45 @@
/* global URLSearchParams, window */

import { configureStore } from "@reduxjs/toolkit"
import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit"
import { loadState, saveState } from "@/common/localStorage"
import { resetLogCounts } from "@/common/debugging"
import SandifyImporter from "@/features/file/SandifyImporter"
import rootReducer from "./rootSlice"
import { loadFont } from "@/features/fonts/fontsSlice"
import { updateLayer } from "@/features/layers/layersSlice"

// Handle side effects
const listenerMiddleware = createListenerMiddleware()

// When a font finishes loading, update any FancyText layers using that font
// to trigger dimension recalculation
listenerMiddleware.startListening({
actionCreator: loadFont.fulfilled,
effect: (action, listenerApi) => {
const fontKey = action.payload // e.g., "Garamond|Regular" or "Bubblegum Sans"
const state = listenerApi.getState()
const layers = state.layers.entities
const [fontName, weight = "Regular"] = fontKey.includes("|")
? fontKey.split("|")
: [fontKey, "Regular"]

Object.values(layers).forEach((layer) => {
if (layer.type === "fancyText" && layer.fancyFont === fontName) {
const layerWeight = layer.fancyFontWeight || "Regular"

if (layerWeight === weight) {
listenerApi.dispatch(
updateLayer({
id: layer.id,
fancyFont: fontName,
fancyFontWeight: weight,
}),
)
}
}
})
},
})

// by default, state is always persisted in local storage
const usePersistedState = true
Expand All @@ -28,7 +63,7 @@ if (reset === "all") {
try {
// double JSON parsing ensures it's valid JSON before we try to import it
persistedState = importer.import(JSON.stringify(persistedState))
persistedState.fonts.loaded = false
persistedState.fonts = { loadedFonts: {}, loadingFonts: {} }
persistedState.images.loaded = false
} catch {
persistedState = undefined
Expand All @@ -39,18 +74,19 @@ if (reset === "all") {
const store = configureStore({
reducer: rootReducer,
preloadedState: persistedState,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})

if (persistState) {
store.subscribe(() => {
const state = store.getState()
if (state.fonts.loaded) {
saveState(state)
resetLogCounts()
// Save state to localStorage (fonts are loaded on-demand now)
saveState(state)
resetLogCounts()

if (reset) {
window.location.href = window.location.pathname
}
if (reset) {
window.location.href = window.location.pathname
}
})
}
Expand Down
196 changes: 173 additions & 23 deletions src/features/fonts/fontsSlice.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,201 @@
// https://fonts.google.com/attribution; see NOTICE for license details
// Bubblegum, EBGaramond, Holtwood, Lobster, Montserrat, Rouge, NotoEmoji - SIL Open Font License 1.1
// OpenSans, Roboto, Mountains of Christmas - Apache License 2.0
// Bubblegum, Caveat, EBGaramond, FiraCode, Holtwood, Lobster, Montserrat, Rouge, NotoEmoji - SIL Open Font License 1.1
// OpenSans, PermanentMarker, Roboto, Mountains of Christmas - Apache License 2.0
// SourceHanSerifCN - SIL Open Font License 1.1, Copyright 2017-2022 Adobe

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
import opentype from "opentype.js"

const globalFonts = {}
export const supportedFonts = {

// Fonts with available weight variants
// Format: { fontName: { weight: url } }
export const fontVariants = {
Caveat: {
Regular: "fonts/Caveat-Regular.ttf",
Bold: "fonts/Caveat-Bold.ttf",
},
"Fira Code": {
Regular: "fonts/FiraCode-Regular.ttf",
Bold: "fonts/FiraCode-Bold.ttf",
},
Garamond: {
Regular: "fonts/EBGaramond-Regular.ttf",
Bold: "fonts/EBGaramond-Bold.ttf",
},
Montserrat: {
Regular: "fonts/Montserrat-Regular.ttf",
Bold: "fonts/Montserrat-Bold.ttf",
},
"Open Sans": {
Regular: "fonts/OpenSans-Regular.ttf",
Bold: "fonts/OpenSans-Bold.ttf",
},
Roboto: {
Regular: "fonts/Roboto-Regular.ttf",
Black: "fonts/Roboto-Black.ttf",
},
"Source Han Serif": {
Regular: "fonts/SourceHanSerifCN-Regular.ttf",
Bold: "fonts/SourceHanSerifCN-Bold.ttf",
},
}

// Fonts without weight variants (single weight only)
const singleWeightFonts = {
"fonts/BubblegumSans-Regular.ttf": "Bubblegum Sans",
"fonts/EBGaramond-Regular.ttf": "Garamond",
"fonts/HoltwoodOneSC-Regular.ttf": "Holtwood",
"fonts/Lobster-Regular.ttf": "Lobster",
"fonts/Montserrat-Bold.ttf": "Montserrat",
"fonts/MountainsofChristmas-Regular.ttf": "Mountains of Christmas",
"fonts/NotoEmoji-VariableFont_wght.ttf": "Noto Emoji",
"fonts/OpenSans-Regular.ttf": "Open Sans",
"fonts/Roboto-Black.ttf": "Roboto",
"fonts/PermanentMarker-Regular.ttf": "Permanent Marker",
"fonts/RougeScript-Regular.ttf": "Rouge Script",
"fonts/MountainsofChristmas-Regular.ttf": "Mountains of Christmas",
}

export const loadFont = createAsyncThunk("fonts/getFont", async (url) => {
const font = await opentype.load(url)
const fontName = supportedFonts[url]
// Build supportedFonts from both sources (for backwards compatibility)
export const supportedFonts = {
...singleWeightFonts,
...Object.fromEntries(
Object.entries(fontVariants).flatMap(([fontName, weights]) =>
Object.entries(weights).map(([weight, url]) => [
url,
`${fontName}|${weight}`,
]),
),
),
}

// List of font names for the dropdown (without weight suffix)
export const fontNames = [
"Bubblegum Sans",
"Caveat",
"Fira Code",
"Garamond",
"Holtwood",
"Lobster",
"Montserrat",
"Mountains of Christmas",
"Noto Emoji",
"Open Sans",
"Permanent Marker",
"Roboto",
"Rouge Script",
"Source Han Serif",
]

globalFonts[fontName] = font
return fontName
})
// Get available weights for a font (returns null if single-weight font)
export const getFontWeights = (fontName) => {
return fontVariants[fontName] ? Object.keys(fontVariants[fontName]) : null
}

// Get the URL for a font + weight combo
export const getFontUrl = (fontName, weight = "Regular") => {
if (fontVariants[fontName]) {
return fontVariants[fontName][weight] || fontVariants[fontName].Regular
}
// Single-weight font - find by name
return Object.entries(singleWeightFonts).find(
([, name]) => name === fontName,
)?.[0]
}

export const getFont = (name) => {
return globalFonts[name]
// Get the cache key for a font (used in globalFonts)
const getFontKey = (fontName, weight) => {
return fontVariants[fontName] ? `${fontName}|${weight}` : fontName
}

// Load font by URL
export const loadFont = createAsyncThunk(
"fonts/getFont",
async (url, { getState }) => {
const fontKey = supportedFonts[url]

if (globalFonts[fontKey]) {
return fontKey
}

const font = await opentype.load(url)

globalFonts[fontKey] = font

return fontKey
},
{
condition: (url, { getState }) => {
const fontKey = supportedFonts[url]
const state = getState().fonts

return !state.loadedFonts[fontKey] && !state.loadingFonts[fontKey]
},
},
)

// Load font by name and optional weight
export const loadFontByName = createAsyncThunk(
"fonts/loadByName",
async ({ fontName, weight = "Regular" }, { dispatch }) => {
const url = getFontUrl(fontName, weight)

if (!url) {
throw new Error(`Unknown font: ${fontName} ${weight}`)
}

await dispatch(loadFont(url))

return getFontKey(fontName, weight)
},
{
condition: ({ fontName, weight = "Regular" }, { getState }) => {
const fontKey = getFontKey(fontName, weight)
const state = getState().fonts

return !state.loadedFonts[fontKey] && !state.loadingFonts[fontKey]
},
},
)

// Get a loaded font by name and optional weight
export const getFont = (fontName, weight = "Regular") => {
const fontKey = getFontKey(fontName, weight)
return globalFonts[fontKey]
}

let loadCount = 0
export const fontsSlice = createSlice({
name: "fonts",
initialState: {
loaded: false,
loadedFonts: {},
loadingFonts: {},
},
reducers: {},
extraReducers: (builder) => {
builder.addCase(loadFont.fulfilled, (state, action) => {
loadCount++
state.loaded = loadCount == Object.keys(supportedFonts).length
})
builder
.addCase(loadFont.pending, (state, action) => {
const url = action.meta.arg
const fontName = supportedFonts[url]
state.loadingFonts[fontName] = true
})
.addCase(loadFont.fulfilled, (state, action) => {
const fontName = action.payload
state.loadedFonts[fontName] = true
delete state.loadingFonts[fontName]
})
.addCase(loadFont.rejected, (state, action) => {
const url = action.meta.arg
const fontName = supportedFonts[url]
delete state.loadingFonts[fontName]
})
},
})

export const selectFontsLoaded = (state) => state.fonts.loaded
// Selectors
export const selectFontLoaded = (state, fontName, weight = "Regular") => {
const fontKey = getFontKey(fontName, weight)
return !!state.fonts.loadedFonts[fontKey]
}

export const selectFontLoading = (state, fontName, weight = "Regular") => {
const fontKey = getFontKey(fontName, weight)
return !!state.fonts.loadingFonts[fontKey]
}

export default fontsSlice.reducer
Loading