diff --git a/public/fonts/Caveat-Bold.ttf b/public/fonts/Caveat-Bold.ttf new file mode 100644 index 00000000..3228e9c1 Binary files /dev/null and b/public/fonts/Caveat-Bold.ttf differ diff --git a/public/fonts/Caveat-Regular.ttf b/public/fonts/Caveat-Regular.ttf new file mode 100644 index 00000000..e538d122 Binary files /dev/null and b/public/fonts/Caveat-Regular.ttf differ diff --git a/public/fonts/EBGaramond-Bold.ttf b/public/fonts/EBGaramond-Bold.ttf new file mode 100644 index 00000000..c7f629e6 Binary files /dev/null and b/public/fonts/EBGaramond-Bold.ttf differ diff --git a/public/fonts/FiraCode-Bold.ttf b/public/fonts/FiraCode-Bold.ttf new file mode 100644 index 00000000..82a931f0 Binary files /dev/null and b/public/fonts/FiraCode-Bold.ttf differ diff --git a/public/fonts/FiraCode-Regular.ttf b/public/fonts/FiraCode-Regular.ttf new file mode 100644 index 00000000..3a57209a Binary files /dev/null and b/public/fonts/FiraCode-Regular.ttf differ diff --git a/public/fonts/Montserrat-Regular.ttf b/public/fonts/Montserrat-Regular.ttf new file mode 100644 index 00000000..c63955ca Binary files /dev/null and b/public/fonts/Montserrat-Regular.ttf differ diff --git a/public/fonts/OpenSans-Bold.ttf b/public/fonts/OpenSans-Bold.ttf new file mode 100644 index 00000000..f9f50341 Binary files /dev/null and b/public/fonts/OpenSans-Bold.ttf differ diff --git a/public/fonts/PermanentMarker-Regular.ttf b/public/fonts/PermanentMarker-Regular.ttf new file mode 100644 index 00000000..3218fc5b Binary files /dev/null and b/public/fonts/PermanentMarker-Regular.ttf differ diff --git a/public/fonts/Roboto-Regular.ttf b/public/fonts/Roboto-Regular.ttf new file mode 100644 index 00000000..c44acd07 Binary files /dev/null and b/public/fonts/Roboto-Regular.ttf differ diff --git a/public/fonts/SourceHanSerifCN-Bold.ttf b/public/fonts/SourceHanSerifCN-Bold.ttf new file mode 100644 index 00000000..df94403c Binary files /dev/null and b/public/fonts/SourceHanSerifCN-Bold.ttf differ diff --git a/public/fonts/SourceHanSerifCN-Regular.ttf b/public/fonts/SourceHanSerifCN-Regular.ttf new file mode 100644 index 00000000..dbafe4ea Binary files /dev/null and b/public/fonts/SourceHanSerifCN-Regular.ttf differ diff --git a/src/components/DropdownOption.js b/src/components/DropdownOption.js index 882ef3af..241a316f 100644 --- a/src/components/DropdownOption.js +++ b/src/components/DropdownOption.js @@ -19,7 +19,7 @@ const DropdownOption = ({ let choices = option.choices if (typeof choices === "function") { - choices = choices() + choices = choices(data) } choices = Array.isArray(choices) diff --git a/src/features/app/Sidebar.js b/src/features/app/Sidebar.js index 9220d6cf..24b8f75e 100644 --- a/src/features/app/Sidebar.js +++ b/src/features/app/Sidebar.js @@ -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 = () => { @@ -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 })), ) diff --git a/src/features/app/store.js b/src/features/app/store.js index 8683c65d..927ca395 100644 --- a/src/features/app/store.js +++ b/src/features/app/store.js @@ -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 @@ -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 @@ -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 } }) } diff --git a/src/features/fonts/fontsSlice.js b/src/features/fonts/fontsSlice.js index a0720a7d..a3b3e5ce 100644 --- a/src/features/fonts/fontsSlice.js +++ b/src/features/fonts/fontsSlice.js @@ -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 diff --git a/src/features/layers/layersSlice.js b/src/features/layers/layersSlice.js index 05a54f86..35074a55 100644 --- a/src/features/layers/layersSlice.js +++ b/src/features/layers/layersSlice.js @@ -29,7 +29,7 @@ import { selectSelectedEffectId, } from "@/features/effects/effectsSlice" import { imagesSlice, addImage, loadImage } from "@/features/images/imagesSlice" -import { selectFontsLoaded } from "@/features/fonts/fontsSlice" +import { selectFontLoaded } from "@/features/fonts/fontsSlice" import { selectImagesLoaded } from "@/features/images/imagesSlice" import { selectCurrentMachine } from "@/features/machines/machinesSlice" import { getMachine } from "@/features/machines/machineFactory" @@ -245,18 +245,20 @@ export const selectLayerMachine = createCachedSelector( const selectLayerDependentsLoaded = createCachedSelector( selectLayerById, - selectFontsLoaded, selectImagesLoaded, - (layer, fontsLoaded, imagesLoaded) => { + (state, id) => state, + (layer, imagesLoaded, state) => { if (!layer) { return false } const shape = getShape(layer.type) - const fontsReady = !shape.usesFonts || fontsLoaded + const fontLoaded = + !shape.usesFonts || + selectFontLoaded(state, layer.fancyFont, layer.fancyFontWeight) const imagesReady = !layer.imageId || imagesLoaded - return fontsReady && imagesReady + return fontLoaded && imagesReady }, )((state, id) => id) @@ -291,6 +293,22 @@ export const selectVisibleLayerIds = createSelector( }, ) +// Check if all fonts needed by visible FancyText layers are loaded +export const selectFontsLoaded = createSelector(selectState, (state) => { + const visibleLayerIds = selectVisibleLayerIds(state) + + return visibleLayerIds.every((id) => { + const layer = selectLayerById(state, id) + + if (layer.type !== "fancyText") return true + return selectFontLoaded( + state, + layer.fancyFont, + layer.fancyFontWeight || "Regular", + ) + }) +}) + export const selectIsDragging = createSelector( [selectLayerIds, selectLayerEntities], (ids, layers) => { @@ -547,9 +565,9 @@ export const selectIsUpstreamEffectDragging = createCachedSelector( // returns a array of all visible machine-bound vertices and the connections between them export const selectConnectedVertices = createSelector(selectState, (state) => { - if (!state.fonts.loaded) { + if (!selectFontsLoaded(state)) { return [] - } // wait for fonts + } log("selectConnectedVertices") const visibleLayerIds = selectVisibleLayerIds(state) @@ -567,9 +585,9 @@ export const selectConnectedVertices = createSelector(selectState, (state) => { // returns an array of layers (and connectors) in an object structure designed to be exported by // an exporter export const selectLayersForExport = createSelector(selectState, (state) => { - if (!state.fonts.loaded) { + if (!selectFontsLoaded(state)) { return [] - } // wait for fonts + } log("selectLayersForExport") const visibleLayerIds = selectVisibleLayerIds(state) diff --git a/src/features/preview/ShapePreview.js b/src/features/preview/ShapePreview.js index f355470e..b5ed5430 100644 --- a/src/features/preview/ShapePreview.js +++ b/src/features/preview/ShapePreview.js @@ -25,6 +25,7 @@ import EffectLayer from "@/features/effects/EffectLayer" import { selectPreviewSliderValue } from "@/features/preview/previewSlice" import EffectPreview from "@/features/preview/EffectPreview" import { getShape } from "@/features/shapes/shapeFactory" +import { loadFontByName } from "@/features/fonts/fontsSlice" import { roundP, scaleByWheel } from "@/common/util" import PreviewHelper from "./PreviewHelper" import { log } from "@/common/debugging" @@ -87,6 +88,18 @@ const ShapePreview = (ownProps) => { const trRef = React.useRef() const model = getShape(layer?.type || "polygon") + // Load font on-demand when a FancyText layer needs it + useEffect(() => { + if (layer && model.usesFonts && layer.fancyFont) { + dispatch( + loadFontByName({ + fontName: layer.fancyFont, + weight: layer.fancyFontWeight || "Regular", + }), + ) + } + }, [dispatch, layer?.fancyFont, layer?.fancyFontWeight, model.usesFonts]) + useEffect(() => { if (layer?.visible && isCurrent && model.canChangeSize(layer)) { trRef.current.nodes([groupRef.current]) diff --git a/src/features/shapes/FancyText.js b/src/features/shapes/FancyText.js index 9bf473a4..3c63f15d 100644 --- a/src/features/shapes/FancyText.js +++ b/src/features/shapes/FancyText.js @@ -15,7 +15,7 @@ import { dimensions, } from "@/common/geometry" import { connectMarkedVerticesAlongMachinePerimeter } from "@/features/machines/util" -import { getFont, supportedFonts } from "@/features/fonts/fontsSlice" +import { getFont, fontNames, getFontWeights } from "@/features/fonts/fontsSlice" const MIN_SPACING_MULTIPLIER = 1.2 const SPECIAL_CHILDREN = ["i", "j", "?"] @@ -28,9 +28,14 @@ const options = { fancyFont: { title: "Font", type: "dropdown", - choices: () => { - return Object.values(supportedFonts) - }, + choices: () => fontNames, + }, + fancyFontWeight: { + title: "Weight", + type: "dropdown", + choices: (data) => getFontWeights(data?.fancyFont) || ["Regular"], + isVisible: (model, data) => + data?.fancyFont && getFontWeights(data.fancyFont) !== null, }, fancyLineSpacing: { title: "Line spacing", @@ -66,6 +71,7 @@ export default class FancyText extends Shape { ...{ fancyText: "Sandify", fancyFont: "Garamond", + fancyFontWeight: "Regular", fancyAlignment: "left", fancyConnectLines: "inside", fancyLineSpacing: 1.0, @@ -75,7 +81,7 @@ export default class FancyText extends Shape { } getVertices(state) { - const font = getFont(state.shape.fancyFont) + const font = getFont(state.shape.fancyFont, state.shape.fancyFontWeight) if (font) { let words = state.shape.fancyText @@ -86,6 +92,14 @@ export default class FancyText extends Shape { } words = words.map((word) => this.drawWord(word, font, state)) + + // Handle case where font doesn't have glyphs for the text (e.g., Chinese in Latin font) + const hasValidVertices = words.some((word) => word.length > 0) + + if (!hasValidVertices) { + return [new Victor(0, 0)] + } + let { offsets, vertices } = this.addVerticalSpacing(words, font, state) horizontalAlign(vertices, state.shape.fancyAlignment) @@ -110,16 +124,40 @@ export default class FancyText extends Shape { // hook to modify updates to a layer before they affect the state handleUpdate(layer, changes) { + // Reset weight to Regular if switching to a font that doesn't have the current weight + if (changes.fancyFont) { + const newWeights = getFontWeights(changes.fancyFont) + if (newWeights && !newWeights.includes(layer.fancyFontWeight)) { + changes.fancyFontWeight = "Regular" + } else if (!newWeights) { + changes.fancyFontWeight = "Regular" + } + } + if ( changes.fancyText !== undefined || changes.fancyFont || + changes.fancyFontWeight || changes.fancyLineSpacing ) { + const newFontName = changes.fancyFont || layer.fancyFont + const newWeight = + changes.fancyFontWeight || layer.fancyFontWeight || "Regular" + const newFont = getFont(newFontName, newWeight) + const oldFont = getFont(layer.fancyFont, layer.fancyFontWeight) + + // Skip dimension recalculation if fonts aren't loaded yet. + // The listener middleware will trigger a re-update when the font loads. + if (!newFont || !oldFont) { + return + } + // default "a" value handles the empty string case to prevent weird resizing const newProps = { ...layer, fancyText: changes.fancyText || layer.fancyText || "a", - fancyFont: changes.fancyFont || layer.fancyFont, + fancyFont: newFontName, + fancyFontWeight: newWeight, } const oldProps = { ...layer, @@ -136,7 +174,7 @@ export default class FancyText extends Shape { oldHeight == 0 ? this.startingHeight : (layer.height * height) / oldHeight - changes.aspectRatio = changes.width / layer.height + changes.aspectRatio = changes.width / changes.height } }