Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[font overview] Implement a form of glyph/character grouping #1903

Merged
merged 15 commits into from
Dec 30, 2024
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
10 changes: 10 additions & 0 deletions src/fontra/client/core/glyph-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
134 changes: 134 additions & 0 deletions src/fontra/client/core/glyph-organizer.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
}
77 changes: 75 additions & 2 deletions src/fontra/client/web-components/glyph-cell-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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;
Expand All @@ -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})`;
}
1 change: 0 additions & 1 deletion src/fontra/views/fontoverview/fontoverview.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions src/fontra/views/fontoverview/fontoverview.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class FontOverviewController extends ViewController {
fontLocationUser: {},
fontLocationSourceMapped: {},
glyphSelection: new Set(),
groupByKeys: [],
});
this.fontOverviewSettings = this.fontOverviewSettingsController.model;

Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/fontra/views/fontoverview/panel-navigation.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -57,13 +59,41 @@ 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",
});

this.appendChild(this.searchField);
this.appendChild(fontSourceSelector);
this.appendChild(groupByContainer);
}
}

Expand Down
Loading