Font selection for text layers (#585)

* Add font dropdown

* Add fonts

* Font tool options

* Fix tests

* Replace http with https

* Add variant selection

* Do not embed default font

* Use proxied font list API

* Change default font to Merriweather

* Remove outdated comment

* Specify font once & load font into foreignobject

* Fix tests

* Rename variant to font_style

* Change TextAreaInput to use FieldInput (WIP, breaks functionality)

* Fix textarea functionality

* Fix types

* Add weight name mapping

* Change labeling of "Italic"

* Remove commented HTML node

* Rename font "name" to "font_family" and "file" "font_file"

* Fix errors

* Fix fmt

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube
2022-04-21 09:50:44 +01:00
committed by Keavon Chambers
parent 916a575446
commit 239aa03453
36 changed files with 1029 additions and 253 deletions

View File

@@ -158,6 +158,10 @@ img {
background-color: var(--color-6-lowergray);
}
}
&::-webkit-scrollbar-corner {
background: none;
}
}
.scrollable-x.scrollable-y {

View File

@@ -287,10 +287,14 @@ import {
TriggerViewportResize,
DisplayRemoveEditableTextbox,
DisplayEditableTextbox,
TriggerFontLoad,
TriggerDefaultFontLoad,
} from "@/dispatcher/js-messages";
import { textInputCleanup } from "@/lifetime/input";
import { loadDefaultFont, setLoadDefaultFontCallback } from "@/utilities/fonts";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
@@ -453,6 +457,14 @@ export default defineComponent({
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
if (this.textInput) this.editor.instance.on_change_text(textInputCleanup(this.textInput.innerText));
});
this.editor.dispatcher.subscribeJsMessage(TriggerFontLoad, (triggerFontLoad) => {
fetch(triggerFontLoad.font)
.then((response) => response.arrayBuffer())
.then((response) => {
this.editor.instance.on_font_load(triggerFontLoad.font, new Uint8Array(response), false);
});
});
this.editor.dispatcher.subscribeJsMessage(TriggerDefaultFontLoad, loadDefaultFont);
this.editor.dispatcher.subscribeJsMessage(TriggerTextCopy, async (triggerTextCopy) => {
// Clipboard API supported?
if (!navigator.clipboard) return;
@@ -514,6 +526,7 @@ export default defineComponent({
// TODO(mfish33): Replace with initialization system Issue:#524
// Get initial Document Bar
this.editor.instance.init_document_bar();
setLoadDefaultFontCallback((font: string, data: Uint8Array) => this.editor.instance.on_font_load(font, data, true));
},
data() {
const documentModeEntries: SectionsOfMenuListEntries = [

View File

@@ -15,7 +15,13 @@
:incrementCallbackDecrease="() => updateLayout(component.widget_id, 'Decrement')"
/>
<TextInput v-if="component.kind === 'TextInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<TextAreaInput v-if="component.kind === 'TextAreaInput'" v-bind="component.props" @commitText="(value: string) => updateLayout(component.widget_id, value)" />
<ColorInput v-if="component.kind === 'ColorInput'" v-bind="component.props" @update:value="(value: string) => updateLayout(component.widget_id, value)" />
<FontInput
v-if="component.kind === 'FontInput'"
v-bind="component.props"
@changeFont="(value: { name: string, style: string, file: string }) => updateLayout(component.widget_id, value)"
/>
<IconButton v-if="component.kind === 'IconButton'" v-bind="component.props" :action="() => updateLayout(component.widget_id, null)" />
<OptionalInput v-if="component.kind === 'OptionalInput'" v-bind="component.props" @update:checked="(value: boolean) => updateLayout(component.widget_id, value)" />
<RadioInput v-if="component.kind === 'RadioInput'" v-bind="component.props" @update:selectedIndex="(value: number) => updateLayout(component.widget_id, value)" />
@@ -28,10 +34,24 @@
<style lang="scss">
.widget-row {
height: 32px;
min-height: 32px;
flex: 0 0 auto;
display: flex;
align-items: center;
> * {
--widget-height: 24px;
min-height: var(--widget-height);
line-height: var(--widget-height);
margin: calc((24px - var(--widget-height)) / 2 + 4px) 0;
&.icon-label.size-12 {
--widget-height: 12px;
}
&.icon-label.size-16 {
--widget-height: 16px;
}
}
}
</style>
@@ -43,9 +63,11 @@ import { WidgetRow } from "@/dispatcher/js-messages";
import IconButton from "@/components/widgets/buttons/IconButton.vue";
import PopoverButton from "@/components/widgets/buttons/PopoverButton.vue";
import ColorInput from "@/components/widgets/inputs/ColorInput.vue";
import FontInput from "@/components/widgets/inputs/FontInput.vue";
import NumberInput from "@/components/widgets/inputs/NumberInput.vue";
import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import RadioInput from "@/components/widgets/inputs/RadioInput.vue";
import TextAreaInput from "@/components/widgets/inputs/TextAreaInput.vue";
import TextInput from "@/components/widgets/inputs/TextInput.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
@@ -73,6 +95,8 @@ export default defineComponent({
TextLabel,
IconLabel,
ColorInput,
FontInput,
TextAreaInput,
},
});
</script>

View File

@@ -2,6 +2,7 @@
<template>
<LayoutRow class="field-input" :class="{ disabled }">
<input
v-if="!textarea"
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
ref="input"
@@ -15,6 +16,22 @@
@keydown.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
/>
<textarea
v-else
:class="{ 'has-label': label }"
:id="`field-input-${id}`"
class="scrollable-y"
data-scrollable-y
ref="input"
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@keydown.ctrl.enter="() => $emit('textChanged')"
@keydown.esc="() => $emit('cancelTextChange')"
></textarea>
<label v-if="label" :for="`field-input-${id}`">{{ label }}</label>
<slot></slot>
</LayoutRow>
@@ -23,7 +40,7 @@
<style lang="scss">
.field-input {
min-width: 80px;
height: 24px;
height: auto;
position: relative;
border-radius: 2px;
background: var(--color-1-nearblack);
@@ -43,7 +60,8 @@
cursor: text;
}
input {
input,
textarea {
flex: 1 1 100%;
width: 0;
min-width: 30px;
@@ -55,6 +73,9 @@
border: none;
background: none;
color: var(--color-e-nearwhite);
}
input {
text-align: center;
&:not(:focus).has-label {
@@ -72,11 +93,20 @@
}
}
textarea {
min-height: calc(18px * 4);
margin: 3px;
padding: 0 5px;
box-sizing: border-box;
resize: vertical;
}
&.disabled {
background: var(--color-2-mildblack);
label,
input {
input,
textarea {
color: var(--color-8-uppergray);
}
}
@@ -95,6 +125,7 @@ export default defineComponent({
label: { type: String as PropType<string>, required: false },
spellcheck: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
textarea: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {

View File

@@ -0,0 +1,165 @@
<template>
<LayoutRow class="dropdown-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
<span>{{ activeEntry.label }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
<MenuList
v-model:activeEntry="activeEntry"
@widthChanged="(newWidth: number) => onWidthChanged(newWidth)"
:menuEntries="menuEntries"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
</LayoutRow>
</template>
<style lang="scss">
.dropdown-input {
position: relative;
.dropdown-box {
align-items: center;
white-space: nowrap;
background: var(--color-1-nearblack);
height: 24px;
border-radius: 2px;
.dropdown-icon {
margin: 4px;
flex: 0 0 auto;
}
span {
margin: 0;
margin-left: 8px;
flex: 1 1 100%;
}
.dropdown-icon + span {
margin-left: 0;
}
.dropdown-arrow {
margin: 6px 2px;
flex: 0 0 auto;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
span {
color: var(--color-f-white);
}
svg {
fill: var(--color-f-white);
}
}
&.open {
border-radius: 2px 2px 0 0;
}
&.disabled {
background: var(--color-2-mildblack);
span {
color: var(--color-8-uppergray);
}
svg {
fill: var(--color-8-uppergray);
}
}
}
.menu-list .floating-menu-container .floating-menu-content {
max-height: 400px;
}
}
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { fontNames, getFontFile, getFontStyles } from "@/utilities/fonts";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import MenuList, { MenuListEntry, SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
export default defineComponent({
emits: ["update:fontFamily", "update:fontStyle", "changeFont"],
props: {
fontFamily: { type: String as PropType<string>, required: true },
fontStyle: { type: String as PropType<string>, required: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
isStyle: { type: Boolean as PropType<boolean>, default: false },
},
data() {
const { menuEntries, activeEntry } = this.updateEntries();
return {
menuEntries,
activeEntry,
minWidth: 0,
};
},
methods: {
clickDropdownBox() {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
},
selectFont(newName: string) {
if (this.isStyle) this.$emit("update:fontStyle", newName);
else this.$emit("update:fontFamily", newName);
{
const fontFamily = this.isStyle ? this.fontFamily : newName;
const fontStyle = this.isStyle ? newName : getFontStyles(newName)[0];
const fontFile = getFontFile(fontFamily, fontStyle);
this.$emit("changeFont", { fontFamily, fontStyle, fontFile });
}
},
onWidthChanged(newWidth: number) {
this.minWidth = newWidth;
},
updateEntries(): { menuEntries: SectionsOfMenuListEntries; activeEntry: MenuListEntry } {
let selectedIndex = -1;
const menuEntries: SectionsOfMenuListEntries = [
(this.isStyle ? getFontStyles(this.fontFamily) : fontNames()).map((name, index) => {
if (name === (this.isStyle ? this.fontStyle : this.fontFamily)) selectedIndex = index;
const result: MenuListEntry = {
label: name,
action: (): void => this.selectFont(name),
};
return result;
}),
];
const activeEntry = selectedIndex < 0 ? { label: "-" } : menuEntries.flat()[selectedIndex];
return { menuEntries, activeEntry };
},
},
watch: {
fontFamily() {
const { menuEntries, activeEntry } = this.updateEntries();
this.menuEntries = menuEntries;
this.activeEntry = activeEntry;
},
fontStyle() {
const { menuEntries, activeEntry } = this.updateEntries();
this.menuEntries = menuEntries;
this.activeEntry = activeEntry;
},
},
components: {
IconLabel,
MenuList,
LayoutRow,
},
});
</script>

View File

@@ -0,0 +1,74 @@
<template>
<FieldInput
:textarea="true"
class="text-area-input"
:class="{ 'has-label': label }"
v-model:value="inputValue"
:label="label"
:spellcheck="true"
:disabled="disabled"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
ref="fieldInput"
></FieldInput>
</template>
<style lang="scss"></style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import FieldInput from "@/components/widgets/inputs/FieldInput.vue";
export default defineComponent({
emits: ["update:value", "commitText"],
props: {
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
},
data() {
return {
editing: false,
};
},
computed: {
inputValue: {
get() {
return this.value;
},
set(value: string) {
this.$emit("update:value", value);
},
},
},
methods: {
onTextFocused() {
this.editing = true;
},
// Called only when `value` is changed from the <textarea> element via user input and committed, either
// via the `change` event or when the <input> element is defocused (with the `blur` event binding)
onTextChanged() {
// The `inputElement.blur()` call in `onCancelTextChange()` causes itself to be run again, so this if statement skips a second run
if (!this.editing) return;
this.onCancelTextChange();
// TODO: Find a less hacky way to do this
const inputElement = (this.$refs.fieldInput as typeof FieldInput).$refs.input as HTMLTextAreaElement;
this.$emit("commitText", inputElement.value);
// Required if value is not changed by the parent component upon update:value event
inputElement.value = this.value;
},
onCancelTextChange() {
this.editing = false;
const inputElement = (this.$refs.fieldInput as typeof FieldInput).$refs.input as HTMLTextAreaElement;
inputElement.blur();
},
},
components: { FieldInput },
});
</script>

View File

@@ -388,6 +388,8 @@ export class IndexedDbDocumentDetails extends DocumentDetails {
id!: string;
}
export class TriggerDefaultFontLoad extends JsMessage {}
export class TriggerIndexedDbWriteDocument extends JsMessage {
document!: string;
@@ -403,6 +405,10 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
document_id!: string;
}
export class TriggerFontLoad extends JsMessage {
font!: string;
}
export interface WidgetLayout {
layout_target: unknown;
layout: LayoutRow[];
@@ -427,7 +433,19 @@ export function isWidgetSection(layoutRow: WidgetRow | WidgetSection): layoutRow
return Boolean((layoutRow as WidgetSection).layout);
}
export type WidgetKind = "NumberInput" | "Separator" | "IconButton" | "PopoverButton" | "OptionalInput" | "RadioInput" | "TextInput" | "TextLabel" | "IconLabel" | "ColorInput";
export type WidgetKind =
| "NumberInput"
| "Separator"
| "IconButton"
| "PopoverButton"
| "OptionalInput"
| "RadioInput"
| "TextInput"
| "TextAreaInput"
| "TextLabel"
| "IconLabel"
| "ColorInput"
| "FontInput";
export interface Widget {
kind: WidgetKind;
@@ -522,9 +540,11 @@ export const messageConstructors: Record<string, MessageMaker> = {
DisplayEditableTextbox,
UpdateImageData,
DisplayRemoveEditableTextbox,
TriggerDefaultFontLoad,
TriggerFileDownload,
TriggerFileUpload,
TriggerIndexedDbRemoveDocument,
TriggerFontLoad,
TriggerIndexedDbWriteDocument,
TriggerTextCommit,
TriggerTextCopy,

View File

@@ -0,0 +1,81 @@
const fontListAPI = "https://api.graphite.rs/font-list";
// Taken from https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
const weightNameMapping = new Map([
[100, "Thin"],
[200, "Extra Light"],
[300, "Light"],
[400, "Normal"],
[500, "Medium"],
[600, "Semi Bold"],
[700, "Bold"],
[800, "Extra Bold"],
[900, "Black"],
[950, "Extra Black"],
]);
type fontCallbackType = (font: string, data: Uint8Array) => void;
let fontList = [] as { family: string; variants: string[]; files: Map<string, string> }[];
let loadDefaultFontCallback = undefined as fontCallbackType | undefined;
fetch(fontListAPI)
.then((response) => response.json())
.then((json) => {
const loadedFonts = json.items as { family: string; variants: string[]; files: { [name: string]: string } }[];
fontList = loadedFonts.map((font) => {
const { family } = font;
const variants = font.variants.map(formatFontStyleName);
const files = new Map(font.variants.map((x) => [formatFontStyleName(x), font.files[x]]));
return { family, variants, files };
});
loadDefaultFont();
});
function formatFontStyleName(fontStyle: string): string {
const isItalic = fontStyle.endsWith("italic");
const weight = fontStyle === "regular" || fontStyle === "italic" ? 400 : parseInt(fontStyle, 10);
let weightName = "";
let bestWeight = Infinity;
weightNameMapping.forEach((nameChecking, weightChecking) => {
if (Math.abs(weightChecking - weight) < bestWeight) {
bestWeight = Math.abs(weightChecking - weight);
weightName = nameChecking;
}
});
return `${weightName}${isItalic ? " Italic" : ""} (${weight})`;
}
export function loadDefaultFont(): void {
const font = getFontFile("Merriweather", "Normal (400)");
if (font) {
fetch(font)
.then((response) => response.arrayBuffer())
.then((response) => {
if (loadDefaultFontCallback) loadDefaultFontCallback(font, new Uint8Array(response));
});
}
}
export function setLoadDefaultFontCallback(callback: fontCallbackType): void {
loadDefaultFontCallback = callback;
loadDefaultFont();
}
export function fontNames(): string[] {
return fontList.map((value) => value.family);
}
export function getFontStyles(fontFamily: string): string[] {
const font = fontList.find((value) => value.family === fontFamily);
return font ? font.variants : [];
}
export function getFontFile(fontFamily: string, fontStyle: string): string | undefined {
const font = fontList.find((value) => value.family === fontFamily);
const fontFile = font && font.files.get(fontStyle);
return fontFile && fontFile.replace("http://", "https://");
}