diff --git a/src/fontra/client/core/glyph-data.js b/src/fontra/client/core/glyph-data.js index 7993fdd2c..bcea487a2 100644 --- a/src/fontra/client/core/glyph-data.js +++ b/src/fontra/client/core/glyph-data.js @@ -98,3 +98,13 @@ export function getCodePointFromGlyphName(glyphName) { return codePoint; } + +export function getGlyphInfoFromCodePoint(codePoint) { + parseGlyphDataCSV(); + return glyphDataByCodePoint.get(codePoint); +} + +export function getGlyphInfoFromGlyphName(glyphName) { + parseGlyphDataCSV(); + return glyphDataByName.get(glyphName); +} diff --git a/src/fontra/client/core/glyph-organizer.js b/src/fontra/client/core/glyph-organizer.js index 40648cd19..3bd2b17f9 100644 --- a/src/fontra/client/core/glyph-organizer.js +++ b/src/fontra/client/core/glyph-organizer.js @@ -1,6 +1,39 @@ +import { getGlyphInfoFromCodePoint, getGlyphInfoFromGlyphName } from "./glyph-data.js"; + +function getGlyphInfo(glyph) { + const codePoint = glyph.codePoints[0]; + return ( + getGlyphInfoFromCodePoint(codePoint) || + getGlyphInfoFromGlyphName(glyph.glyphName) || + getGlyphInfoFromGlyphName(getBaseGlyphName(glyph.glyphName)) + ); +} + +function getGroupingInfo(glyph, options) { + const glyphInfo = getGlyphInfo(glyph); + return { + ...Object.fromEntries( + Object.entries(glyphInfo || {}).filter(([key, value]) => options[key]) + ), + glyphNameExtension: options.glyphNameExtension + ? getGlyphNameExtension(glyph.glyphName) + : undefined, + }; +} + +const groupProperties = [ + "script", + "category", + "case", + "subCategory", + "glyphNameExtension", +]; + export class GlyphOrganizer { constructor() { this._glyphNamesListFilterFunc = (item) => true; // pass all through + + this.setGroupings([]); } setSearchString(searchString) { @@ -12,6 +45,17 @@ export class GlyphOrganizer { this._glyphNamesListFilterFunc = (item) => glyphFilterFunc(item, searchItems); } + setGroupings(groupings) { + const options = {}; + groupings.forEach((grouping) => (options[grouping] = true)); + + this.setGroupingFunc((glyph) => getGroupingKey(glyph, options)); + } + + setGroupingFunc(groupingFunc) { + this._groupingFunc = groupingFunc; + } + sortGlyphs(glyphs) { glyphs = [...glyphs]; glyphs.sort(glyphItemSortFunc); @@ -21,6 +65,54 @@ export class GlyphOrganizer { filterGlyphs(glyphs) { return glyphs.filter(this._glyphNamesListFilterFunc); } + + groupGlyphs(glyphs) { + const groups = new Map(); + + for (const item of glyphs) { + const groupingInfo = this._groupingFunc(item); + let group = groups.get(groupingInfo.groupingKey); + if (!group) { + group = { groupingInfo, glyphs: [] }; + groups.set(groupingInfo.groupingKey, group); + } + group.glyphs.push(item); + } + + const groupEntries = [...groups.values()]; + groupEntries.sort(compareGroupInfo); + + const sections = groupEntries.map(({ groupingInfo, glyphs }) => ({ + label: groupingInfo.groupingKey, + glyphs: glyphs, + })); + + return sections; + } +} + +function compareGroupInfo(groupingEntryA, groupingEntryB) { + const groupingInfoA = groupingEntryA.groupingInfo; + const groupingInfoB = groupingEntryB.groupingInfo; + + for (const prop of groupProperties) { + const valueA = groupingInfoA[prop]; + const valueB = groupingInfoB[prop]; + + if (valueA === valueB) { + continue; + } + + if (valueA === undefined) { + return 1; + } else if (valueB === undefined) { + return -1; + } + + return valueA < valueB ? -1 : 1; + } + + return 0; } function glyphFilterFunc(item, searchItems) { @@ -61,3 +153,45 @@ function compare(a, b) { return 1; } } + +function getGlyphNameExtension(glyphName) { + const i = glyphName.lastIndexOf("."); + return i >= 1 ? glyphName.slice(i) : ""; +} + +function getBaseGlyphName(glyphName) { + const i = glyphName.indexOf("."); + return i >= 1 ? glyphName.slice(0, i) : ""; +} + +function getGroupingKey(glyph, options) { + const groupingInfo = getGroupingInfo(glyph, options); + + let groupingKey = ""; + + if (groupingInfo.category) { + groupingKey += groupingInfo.category; + } + + if (groupingInfo.subCategory) { + groupingKey += (groupingKey ? " / " : "") + groupingInfo.subCategory; + } + + if (groupingInfo.case) { + groupingKey += (groupingKey ? " / " : "") + groupingInfo.case; + } + + if (groupingInfo.script) { + groupingKey += (groupingKey ? " " : "") + `(${groupingInfo.script})`; + } + + if (groupingInfo.glyphNameExtension) { + groupingKey += (groupingKey ? " " : "") + `(*${groupingInfo.glyphNameExtension})`; + } + + if (!groupingKey) { + groupingKey = "Other"; + } + + return { groupingKey, ...groupingInfo }; +} diff --git a/src/fontra/client/web-components/glyph-cell-view.js b/src/fontra/client/web-components/glyph-cell-view.js index 58a5a77fc..931199805 100644 --- a/src/fontra/client/web-components/glyph-cell-view.js +++ b/src/fontra/client/web-components/glyph-cell-view.js @@ -49,6 +49,19 @@ export class GlyphCellView extends HTMLElement { }); }); + // // Pinch magnify: this works well for small fonts, but very badly for big fonts + // this.addEventListener("wheel", (event) => { + // if (!event.ctrlKey && !event.altKey) { + // return; + // } + // event.preventDefault(); + // const clunkyScrollWheel = false; + // let { deltaX, deltaY, wheelDeltaX, wheelDeltaY } = event; + // const scaleDown = clunkyScrollWheel ? 500 : event.ctrlKey ? 100 : 300; + // const zoomFactor = 1 - deltaY / scaleDown; + // this.magnification = this.magnification * zoomFactor; + // }); + this.appendChild(this.getContentElement()); this.addEventListener("keydown", (event) => this.handleKeyDown(event)); @@ -75,6 +88,11 @@ export class GlyphCellView extends HTMLElement { overflow-y: auto; white-space: normal; } + + .glyph-count { + font-weight: normal; + opacity: 50%; + } `); return html.div({}, [this.accordion]); // wrap in div for scroll behavior @@ -86,7 +104,13 @@ export class GlyphCellView extends HTMLElement { let sectionIndex = 0; const accordionItems = glyphSections.map((section) => ({ - label: section.label, + label: html.span({}, [ + section.label, + html.span({ class: "glyph-count" }, [ + " ", + makeGlyphCountString(section.glyphs, this.fontController.glyphMap), + ]), + ]), open: true, content: html.div({ class: "font-overview-accordion-item" }, []), glyphs: section.glyphs, @@ -383,6 +407,18 @@ export class GlyphCellView extends HTMLElement { this.glyphSelection = new Set([nextCell.glyphName]); } + // If the cell is in the top row, make sure the *header* is in view + const leftMostCell = leftMostSibling(nextCell); + if (!leftMostCell.previousElementSibling) { + const header = nextCell.parentElement.parentElement.previousElementSibling; + assert(header.classList.contains("ui-accordion-item-header")); + header.scrollIntoView({ + behavior: "auto", + block: "nearest", + inline: "nearest", + }); + } + nextCell.scrollIntoView({ behavior: "auto", block: "nearest", @@ -412,7 +448,20 @@ function nextGlyphCellHorizontal(glyphCell, direction) { if (!nextCell) { const accordionItem = glyphCell.parentNode.parentNode.parentNode; assert(accordionItem.classList.contains("ui-accordion-item")); - const nextAccordionItem = nextSibling(accordionItem, direction); + + let nextAccordionItem = accordionItem; + + while (true) { + nextAccordionItem = nextSibling(nextAccordionItem, direction); + if (!nextAccordionItem) { + break; + } + if (!nextAccordionItem.classList.contains("ui-accordion-item-closed")) { + // Skip closed items + break; + } + } + if (nextAccordionItem) { nextCell = nextAccordionItem.querySelector( `glyph-cell:${direction == 1 ? "first" : "last"}-child` @@ -478,6 +527,19 @@ function boundsCenterX(rect) { return rect.left + rect.width / 2; } +function leftMostSibling(nextCell) { + const top = nextCell.getBoundingClientRect().top; + + while (true) { + const candidateCell = nextCell.previousElementSibling; + if (!candidateCell || candidateCell.getBoundingClientRect().top != top) { + break; + } + nextCell = candidateCell; + } + return nextCell; +} + function cellCompare(cellA, cellB) { if (cellA == cellB) { return 0; @@ -487,3 +549,14 @@ function cellCompare(cellA, cellB) { ? 1 : -1; } + +function makeGlyphCountString(glyphs, glyphMap) { + const numGlyphs = glyphs.length; + const numDefinedGlyphs = glyphs.filter( + (glyph) => glyphMap[glyph.glyphName] !== undefined + ).length; + + return numGlyphs === numDefinedGlyphs + ? `(${numGlyphs})` + : `(${numDefinedGlyphs}/${numGlyphs})`; +} diff --git a/src/fontra/views/fontoverview/fontoverview.css b/src/fontra/views/fontoverview/fontoverview.css index 19aaf0c89..6b5e63c82 100644 --- a/src/fontra/views/fontoverview/fontoverview.css +++ b/src/fontra/views/fontoverview/fontoverview.css @@ -39,7 +39,6 @@ font-overview-navigation { grid-template-columns: max-content auto; align-items: center; gap: 0.666em; - padding-bottom: 1em; } .font-overview-section-header { diff --git a/src/fontra/views/fontoverview/fontoverview.js b/src/fontra/views/fontoverview/fontoverview.js index 8270766ca..6480ddae6 100644 --- a/src/fontra/views/fontoverview/fontoverview.js +++ b/src/fontra/views/fontoverview/fontoverview.js @@ -37,6 +37,7 @@ export class FontOverviewController extends ViewController { fontLocationUser: {}, fontLocationSourceMapped: {}, glyphSelection: new Set(), + groupByKeys: [], }); this.fontOverviewSettings = this.fontOverviewSettingsController.model; @@ -64,6 +65,11 @@ export class FontOverviewController extends ViewController { this.updateGlyphSelection(); }); + this.fontOverviewSettingsController.addKeyListener("groupByKeys", (event) => { + this.glyphOrganizer.setGroupings(event.newValue); + this.updateGlyphSelection(); + }); + this.glyphOrganizer = new GlyphOrganizer(); const rootSubscriptionPattern = {}; @@ -110,9 +116,8 @@ export class FontOverviewController extends ViewController { this.glyphCellView.parentElement.scrollTop = 0; const glyphItemList = this.glyphOrganizer.filterGlyphs(this._glyphItemList); - this.glyphCellView.setGlyphSections([ - { label: "All glyphs", glyphs: glyphItemList }, - ]); + const glyphSections = this.glyphOrganizer.groupGlyphs(glyphItemList); + this.glyphCellView.setGlyphSections(glyphSections); } handleDoubleClick(event, glyphCell) { diff --git a/src/fontra/views/fontoverview/panel-navigation.js b/src/fontra/views/fontoverview/panel-navigation.js index 04d0dba3e..0d5f52d1e 100644 --- a/src/fontra/views/fontoverview/panel-navigation.js +++ b/src/fontra/views/fontoverview/panel-navigation.js @@ -1,6 +1,8 @@ import { GlyphOrganizer } from "/core/glyph-organizer.js"; import * as html from "/core/html-utils.js"; import { translate } from "/core/localization.js"; +import { ObservableController } from "/core/observable-object.js"; +import { labeledCheckbox } from "/core/ui-utils.js"; import { GlyphSearchField } from "/web-components/glyph-search-field.js"; export class FontOverviewNavigation extends HTMLElement { @@ -57,6 +59,33 @@ export class FontOverviewNavigation extends HTMLElement { ] ); + const groupByProperties = [ + ["script", "Script"], + ["category", "Category"], + ["subCategory", "Sub-category"], + ["case", "Case"], + ["glyphNameExtension", "Glyph name extension"], + ]; + + const groupByKeys = groupByProperties.map((item) => item[0]); + + const groupByController = new ObservableController({}); + + groupByController.addKeyListener( + groupByKeys, + (event) => + (this.fontOverviewSettings.groupByKeys = groupByKeys.filter( + (key) => groupByController.model[key] + )) + ); + + const groupByContainer = html.div({}, [ + html.span({}, ["Group by"]), + ...groupByProperties.map(([key, label]) => + labeledCheckbox(label, groupByController, key) + ), + ]); + this.searchField = new GlyphSearchField({ settingsController: this.fontOverviewSettingsController, searchStringKey: "searchString", @@ -64,6 +93,7 @@ export class FontOverviewNavigation extends HTMLElement { this.appendChild(this.searchField); this.appendChild(fontSourceSelector); + this.appendChild(groupByContainer); } }