blob: e9a4cbd9af7719a08b17e198d3dfe7d863e50302 [file] [log] [blame] [edit]
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
<link href="https://fonts.google.com/metadata/fonts" type="application/json" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<style>
.font-view{
font-size: 72pt;
}
</style>
<body>
<div id="app">
<div class="flex items-center gap-4 mb-4">
<button class="btn btn-primary" @click="addPanel">+</button>
<select v-model="selectedSampleText" class="select select-xs select-bordered w-full max-w-xs" @change="applySampleText">
<option v-for="sample in sampleTexts" :value="sample">{{ sample }}</option>
</select>
<div class="flex items-center gap-2">
<label class="label-text">Font size:</label>
<input type="range" min="12" max="200" step="1" v-model.number="fontSize" class="range range-xs w-32"/>
<input type="number" min="12" max="200" v-model.number="fontSize" class="input input-xs w-16"/>
<span class="label-text-alt">pt</span>
</div>
</div>
<div class="grid grid-cols-1 gap-8" ref="panelList">
<font-panel
v-for="(panel, idx) in panels"
:key="panel.id"
:panel.sync="panels[idx]"
:family-data="familyData"
:font-urls="fontUrls"
:font-size="fontSize"
@delete="panels.splice(idx, 1)"
/>
</div>
</div>
</body>
<script>
Vue.component('font-panel', {
props: ['panel', 'familyData', 'fontUrls', 'fontSize'],
computed: {
styleClass() {
let res = `font-family: \"${this.panel.currentFamily}\"; font-variation-settings:`
const data = this.familyData[this.panel.currentFamily]
for (let ax of data.axes) {
res += ` '${ax.tag}' ${this.panel.positions[ax.tag] || 100},`;
}
res = res.slice(0, -1) + ';';
res += ` font-size: ${this.fontSize}pt;`;
return res;
},
axes() {
return this.familyData[this.panel.currentFamily]?.axes || [];
}
},
template: `
<div class="card bg-base-100 shadow-xl p-6">
<div class="flex flex-col items-center mb-2 relative">
<span class="cursor-move handle text-lg select-none absolute left-1/2 -translate-x-1/2 top-0 z-10" title="Drag to reorder" style="line-height:1;">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="5" cy="5" r="1.5" fill="#888"/>
<circle cx="10" cy="5" r="1.5" fill="#888"/>
<circle cx="15" cy="5" r="1.5" fill="#888"/>
<circle cx="5" cy="10" r="1.5" fill="#888"/>
<circle cx="10" cy="10" r="1.5" fill="#888"/>
<circle cx="15" cy="10" r="1.5" fill="#888"/>
</svg>
</span>
<div class="flex justify-between items-center w-full mt-4">
<div id="fonts">
<link v-for="url in fontUrls" :href="url" rel="stylesheet">
</div>
<button class="btn btn-xs btn-error" @click="$emit('delete')">-</button>
</div>
</div>
<select v-model="panel.currentFamily" class="select select-xs select-bordered w-full max-w-xs">
<option v-for="font in familyData">{{ font.family }}</option>
</select>
<div class="font-view" contenteditable="true" :style="styleClass">
{{ panel.text }}
</div>
<div v-for="axis in axes" class="form-control">
<label class="label">
<span class="label-text-alt">{{ axis.tag }}: {{ panel.positions[axis.tag] }}</span>
</label>
<input type="range" class="range range-xs" step="0.1" v-model="panel.positions[axis.tag]" :min="axis.min" :max="axis.max"/>
</div>
</div>
`
});
new Vue({
el: '#app',
data() {return {
familyData: {},
fontUrls: [],
panels: [],
nextPanelId: 1,
restoring: false, // prevent infinite loop when restoring from URL
sampleTexts: [
'Hello world',
'The quick brown fox jumps over the lazy dog.',
'Sphinx of black quartz, judge my vow.',
'1234567890',
'Grumpy wizards make toxic brew for the evil Queen and Jack.'
],
selectedSampleText: 'Hello world',
fontSize: 72,
}},
async created() {
this.familyData = await this.getFamilyData();
this.loadFonts();
this.restorePanelsFromUrl();
if (this.panels.length === 0) this.addPanel();
this.$watch('panels', this.updateUrlFromPanels, { deep: true });
console.log('Vue instance mounted');
},
mounted() {
// Enable drag-and-drop sorting with SortableJS
const vm = this;
Sortable.create(this.$refs.panelList, {
animation: 150,
handle: '.handle', // Only allow drag on the handle
ghostClass: 'bg-base-200',
onEnd(evt) {
if (evt.oldIndex === evt.newIndex) return;
const moved = vm.panels.splice(evt.oldIndex, 1)[0];
vm.panels.splice(evt.newIndex, 0, moved);
}
});
},
methods: {
async getFamilyData() {
return await fetch("family_data.json").then(response => response.json()).then(data => {
let results = {};
let familyMeta = data["familyMetadataList"]
familyMeta.forEach(family => {
if (family.axes.length === 0) {
// skip static fonts
return;
}
results[family.family] = family;
})
return results
})
},
loadFonts() {
let results = [];
for (k in this.familyData) {
const family = this.familyData[k];
let path = `https://fonts.googleapis.com/css2?family=${family.family.replaceAll(" ", "+")}`
const sortedUpperCaseAxes = []
const sortedLowerCaseAxes = []
if (family.axes.length === 0) {
continue
}
for (let a of family.axes) {
if (a.tag.toUpperCase() === a.tag) {
sortedUpperCaseAxes.push(a);
} else {
sortedLowerCaseAxes.push(a);
}
}
sortedLowerCaseAxes.sort((a, b) => a.tag.localeCompare(b.tag));
sortedUpperCaseAxes.sort((a, b) => a.tag.localeCompare(b.tag));
const sortedAxes = [...sortedLowerCaseAxes, ...sortedUpperCaseAxes]
path += ":" + sortedAxes.map(a => {return a.tag}).join(",")
path += "@";
path += sortedAxes.map(axis => {return `${Number(axis.min)}..${Number(axis.max)}`}).join(",")
results.push(path);
}
this.fontUrls = results;
return results
},
addPanel(panelData) {
// Default to first family in familyData
const firstFamily = Object.keys(this.familyData)[0] || 'Roboto';
let family = firstFamily;
let axes = this.familyData[family]?.axes || [];
let positions = {};
let text = 'Hello world';
if (panelData) {
family = panelData.currentFamily || family;
axes = this.familyData[family]?.axes || [];
positions = { ...panelData.positions };
text = panelData.text || text;
} else {
axes.forEach(ax => { positions[ax.tag] = ax.defaultValue || ax.min; });
}
this.panels.push({
id: this.nextPanelId++,
currentFamily: family,
positions: { ...positions },
text,
});
},
updateUrlFromPanels() {
if (this.restoring) return;
const panelsForUrl = this.panels.map(p => ({
family: p.currentFamily,
positions: p.positions,
text: p.text,
}));
const encoded = encodeURIComponent(JSON.stringify(panelsForUrl));
const url = new URL(window.location.href);
url.searchParams.set('panels', encoded);
window.history.replaceState({}, '', url);
},
restorePanelsFromUrl() {
const url = new URL(window.location.href);
const panelsParam = url.searchParams.get('panels');
if (panelsParam) {
try {
this.restoring = true;
const panelsArr = JSON.parse(decodeURIComponent(panelsParam));
panelsArr.forEach(panel => this.addPanel({
currentFamily: panel.family,
positions: panel.positions,
text: panel.text,
}));
} catch (e) { /* ignore */ }
this.restoring = false;
}
},
applySampleText() {
this.panels.forEach(panel => { panel.text = this.selectedSampleText; });
},
}
});
</script>