Add multiple fonts
This commit is contained in:
+3
-1
@@ -265,7 +265,9 @@ class Journal extends PhpObject {
|
||||
],
|
||||
'from' => self::ENTRY_TABLE,
|
||||
'constraint'=> [Db::getId(User::USER_TABLE) => $this->oUser->getUserId()],
|
||||
'orderBy' => [$sIdColumn => 'ASC']
|
||||
//By when it was written, not by when it was inserted. The id is only
|
||||
//the tiebreaker for two entries begun in the same second.
|
||||
'orderBy' => ['started_on' => 'ASC', $sIdColumn => 'ASC']
|
||||
]);
|
||||
|
||||
return array_map(static function(array $asRow): array {
|
||||
|
||||
+32
-1
@@ -20,6 +20,35 @@ class MyThoughts extends Main {
|
||||
public const PROJECT_NAME = 'MyThoughts';
|
||||
public const DEFAULT_LANG = 'en';
|
||||
|
||||
/**
|
||||
* The hands the book can be written in.
|
||||
*
|
||||
* One list, here, because two things need it and they must not drift: the
|
||||
* settings picker offers it, and updateSettings() validates against it.
|
||||
*
|
||||
* `scale` is the type size as a fraction of one ruled line. It is not
|
||||
* cosmetic - every one of these hands has a different x-height for the same
|
||||
* em, so a single size would leave one sitting on the rule and the next
|
||||
* floating above it. The values are measured, not chosen.
|
||||
*
|
||||
* Adding one here also needs its webfont imported in src/scripts/fonts.js.
|
||||
*/
|
||||
public const HANDS = [
|
||||
['id' => 'caveat', 'name' => 'Caveat', 'family' => '"Caveat Variable", "Caveat"', 'scale' => 0.82],
|
||||
['id' => 'kalam', 'name' => 'Kalam', 'family' => '"Kalam"', 'scale' => 0.81],
|
||||
['id' => 'patrick', 'name' => 'Patrick Hand', 'family' => '"Patrick Hand"', 'scale' => 0.8],
|
||||
['id' => 'shadows', 'name' => 'Shadows Into Light', 'family' => '"Shadows Into Light"', 'scale' => 0.7],
|
||||
['id' => 'architect', 'name' => "Architect's Daughter",'family' => '"Architects Daughter"', 'scale' => 0.68],
|
||||
['id' => 'indie', 'name' => 'Indie Flower', 'family' => '"Indie Flower"', 'scale' => 0.67]
|
||||
];
|
||||
|
||||
public const DEFAULT_HAND = 'caveat';
|
||||
|
||||
/** @return string[] ids of every hand on offer */
|
||||
public static function getHandIds(): array {
|
||||
return array_column(self::HANDS, 'id');
|
||||
}
|
||||
|
||||
//The dictionaries shipped in resources/lang. Listed rather than globbed:
|
||||
//Translator resolves its folder relative to the calling script, and the
|
||||
//settings panel needs the list on a page load either way.
|
||||
@@ -62,7 +91,7 @@ class MyThoughts extends Main {
|
||||
protected function getSqlOptions() {
|
||||
return [
|
||||
'tables' => [
|
||||
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'language', 'timezone', 'clearance'],
|
||||
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'language', 'timezone', 'hand', 'clearance'],
|
||||
Journal::ENTRY_TABLE=> [Db::getId(User::USER_TABLE), 'content', 'status', 'started_on', 'closed_on', 'timezone']
|
||||
],
|
||||
'types' => [
|
||||
@@ -71,6 +100,7 @@ class MyThoughts extends Main {
|
||||
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
'token' => "VARCHAR(64) NOT NULL DEFAULT ''",
|
||||
'token_exp' => 'TIMESTAMP DEFAULT 0',
|
||||
'hand' => "VARCHAR(20) NOT NULL DEFAULT '".self::DEFAULT_HAND."'",
|
||||
'language' => 'VARCHAR(2)',
|
||||
'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name
|
||||
'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER,
|
||||
@@ -102,6 +132,7 @@ class MyThoughts extends Main {
|
||||
'consts' => [
|
||||
'title' => self::PROJECT_NAME,
|
||||
'languages' => self::LANGUAGES,
|
||||
'hands' => self::HANDS,
|
||||
'chunk_size' => Journal::CHUNK_SIZE,
|
||||
'default_timezone' => Settings::TIMEZONE,
|
||||
'autosave_delay' => 1200, //ms of stillness before a save
|
||||
|
||||
+4
-1
@@ -26,6 +26,7 @@ class User extends PhpObject {
|
||||
'email' => '',
|
||||
'language' => '',
|
||||
'timezone' => '',
|
||||
'hand' => MyThoughts::DEFAULT_HAND,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
];
|
||||
|
||||
@@ -116,6 +117,7 @@ class User extends PhpObject {
|
||||
'password' => password_hash($sPassword, PASSWORD_DEFAULT),
|
||||
'language' => $sLang,
|
||||
'timezone' => $sTimezone,
|
||||
'hand' => MyThoughts::DEFAULT_HAND,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
]);
|
||||
|
||||
@@ -162,11 +164,12 @@ class User extends PhpObject {
|
||||
|
||||
public function updateSettings(string $sField, string $sValue): array {
|
||||
if(!$this->isLoggedIn()) return MyThoughts::getResult(false, MyThoughts::UNAUTHORIZED);
|
||||
if(!in_array($sField, ['name', 'language', 'timezone'], true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
if(!in_array($sField, ['name', 'language', 'timezone', 'hand'], true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
$sValue = mb_substr(trim($sValue), 0, self::MAX_NAME_LENGTH);
|
||||
if($sField === 'name' && $sValue === '') return MyThoughts::getResult(false, 'account.name_required');
|
||||
if($sField === 'timezone' && !in_array($sValue, \DateTimeZone::listIdentifiers(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
if($sField === 'hand' && !in_array($sValue, MyThoughts::getHandIds(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
|
||||
|
||||
if(!$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [$sField => $sValue])) {
|
||||
return MyThoughts::getResult(false, 'error.commit_db');
|
||||
|
||||
Generated
+50
@@ -9,6 +9,11 @@
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/caveat": "^5.3.0",
|
||||
"@fontsource/architects-daughter": "^5.3.0",
|
||||
"@fontsource/indie-flower": "^5.3.0",
|
||||
"@fontsource/kalam": "^5.3.0",
|
||||
"@fontsource/patrick-hand": "^5.3.0",
|
||||
"@fontsource/shadows-into-light": "^5.3.0",
|
||||
"sass": "^1.103.1",
|
||||
"vue": "^3.5.42"
|
||||
},
|
||||
@@ -204,6 +209,51 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/architects-daughter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/architects-daughter/-/architects-daughter-5.3.0.tgz",
|
||||
"integrity": "sha512-uezOgAlbhmf9nDW69VYq31lXNA+0uRFWXBGCSFWLXVcrNESFHpNjvu11kJss1PMsfH/YKz1hnnYB+JSNI/t+ew==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/indie-flower": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/indie-flower/-/indie-flower-5.3.0.tgz",
|
||||
"integrity": "sha512-Wo5eN/ruEQcyC3+ArzhlpqWQgph3VuCNbJlEkVw17LUd0uyTHOb9bTec4g9njQzjiL/ijACF27sSwy90L6kZBg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/kalam": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/kalam/-/kalam-5.3.0.tgz",
|
||||
"integrity": "sha512-FDkVoDfPDCSN/eO81FOYtUI67eMgpVBJmjBtAqSL0PmEhJyeFY2biC+CR+yEttksgXR9+swpFdYmQTyjwBq80Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/patrick-hand": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/patrick-hand/-/patrick-hand-5.3.0.tgz",
|
||||
"integrity": "sha512-V8C38IlIFfdWg0Xtri3F7sLCVpm069rUwn6w4n+oyn38gt7H4arOJmuReg3tAw1Q6jHqZp89wTnAAJagq4Atxw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/shadows-into-light": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/shadows-into-light/-/shadows-into-light-5.3.0.tgz",
|
||||
"integrity": "sha512-3dvv0i8/ZwLMY08AAlTLivHUdNQYB6Fxe92d7BZiGbHY2GQnCxEd8JC9CBzxTiP4iyWNa6OZn9LXF+xjc9LaGw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
"author": "Franzz",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/caveat": "^5.3.0",
|
||||
"@fontsource/architects-daughter": "^5.3.0",
|
||||
"@fontsource/indie-flower": "^5.3.0",
|
||||
"@fontsource/kalam": "^5.3.0",
|
||||
"@fontsource/patrick-hand": "^5.3.0",
|
||||
"@fontsource/shadows-into-light": "^5.3.0",
|
||||
"sass": "^1.103.1",
|
||||
"vue": "^3.5.42"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
"saved": "Saved.",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"timezone": "Time zone"
|
||||
"timezone": "Time zone",
|
||||
"hand": "Handwriting",
|
||||
"hand_sample": "The morning came"
|
||||
},
|
||||
"book": {
|
||||
"title": "MyThoughts",
|
||||
@@ -45,7 +47,9 @@
|
||||
"loading": "Turning pages…",
|
||||
"today": "Today",
|
||||
"go_to_writing": "Back to today's page",
|
||||
"empty_entry": "Blank page"
|
||||
"empty_entry": "Blank page",
|
||||
"earlier": "Earlier entries",
|
||||
"later": "Later entries"
|
||||
},
|
||||
"action": {
|
||||
"prev_page": "Previous page",
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
"saved": "Enregistré.",
|
||||
"settings": "Réglages",
|
||||
"language": "Langue",
|
||||
"timezone": "Fuseau horaire"
|
||||
"timezone": "Fuseau horaire",
|
||||
"hand": "Écriture",
|
||||
"hand_sample": "Le matin est venu"
|
||||
},
|
||||
"book": {
|
||||
"title": "MyThoughts",
|
||||
@@ -45,7 +47,9 @@
|
||||
"loading": "On tourne les pages…",
|
||||
"today": "Aujourd'hui",
|
||||
"go_to_writing": "Revenir à la page du jour",
|
||||
"empty_entry": "Page blanche"
|
||||
"empty_entry": "Page blanche",
|
||||
"earlier": "Entrées précédentes",
|
||||
"later": "Entrées suivantes"
|
||||
},
|
||||
"action": {
|
||||
"prev_page": "Page précédente",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//Librairies
|
||||
import 'vite/modulepreload-polyfill';
|
||||
import { applyHand } from '@scripts/hands';
|
||||
import Api from '@scripts/api';
|
||||
import Journal from '@scripts/journal';
|
||||
import Lang from '@scripts/lang';
|
||||
@@ -36,6 +37,10 @@ const oApi = new Api({
|
||||
});
|
||||
|
||||
const oUser = reactive({...appConfig.user});
|
||||
|
||||
//Before the first paint, so the book is never drawn in one hand and then
|
||||
//redrawn in another
|
||||
applyHand(appConfig.consts.hands, oUser.hand);
|
||||
const oJournal = reactive(new Journal(oApi, appConfig.consts));
|
||||
|
||||
//Mount app
|
||||
|
||||
+89
-11
@@ -8,7 +8,8 @@ import { getDayKey } from '@scripts/time';
|
||||
//the book paginates one page per view instead.
|
||||
const SINGLE_PAGE_WIDTH = 860;
|
||||
|
||||
const TURN_MS = 700;
|
||||
//Must outlast $turn in _var.scss, or the leaf is torn away mid-flight
|
||||
const TURN_MS = 500;
|
||||
|
||||
/**
|
||||
* The open book.
|
||||
@@ -34,7 +35,7 @@ export default {
|
||||
bookPage
|
||||
},
|
||||
emits: ['reading'],
|
||||
inject: ['journal', 'lang'],
|
||||
inject: ['journal', 'lang', 'user'],
|
||||
data() {
|
||||
return {
|
||||
iSpread: 0,
|
||||
@@ -55,6 +56,17 @@ export default {
|
||||
asCaret: null,
|
||||
asSelection: [],
|
||||
|
||||
//Whether the book should keep the caret in view. True while writing,
|
||||
//false once the reader has deliberately turned or jumped somewhere
|
||||
//else - otherwise the next re-measure drags them back to the page
|
||||
//being written on.
|
||||
bFollowCaret: true,
|
||||
|
||||
//An entry jumped to from the rail or the calendar. Held so that
|
||||
//"where you are" is the entry you asked for, not whichever one
|
||||
//happens to open the page it starts on.
|
||||
iFocusId: 0,
|
||||
|
||||
//The leaf in flight: {id, dir, front, back, from} while a page turns
|
||||
asTurn: null,
|
||||
iTurnId: 0,
|
||||
@@ -144,9 +156,18 @@ export default {
|
||||
offset: iOffset
|
||||
};
|
||||
},
|
||||
//What the rail highlights and the calendar opens on: the entry the
|
||||
//visible spread starts with.
|
||||
/**
|
||||
* What the rail highlights and the calendar opens on.
|
||||
*
|
||||
* Normally the entry the visible spread starts with. But an entry can
|
||||
* begin partway down a page that opens with the tail of an earlier one -
|
||||
* and when you have just clicked that entry's bookmark, the one you
|
||||
* asked for is where you are, not the one above it. So a jumped-to entry
|
||||
* holds the position for as long as it is on the spread.
|
||||
*/
|
||||
currentEntryId() {
|
||||
if((this.iFocusId > 0) && this.spreadShows(this.iFocusId)) return this.iFocusId;
|
||||
|
||||
for(const oPage of this.visiblePages) {
|
||||
if(oPage.lines.length > 0) return oPage.lines[0].id;
|
||||
}
|
||||
@@ -196,6 +217,13 @@ export default {
|
||||
iPagesPerView() {
|
||||
this.$nextTick(() => this.measure());
|
||||
},
|
||||
//A different hand breaks the lines in different places, so the whole
|
||||
//book has to be re-measured and re-flowed - nothing about the old
|
||||
//layout survives a change of font.
|
||||
'user.hand'() {
|
||||
this.oPaginator.reset();
|
||||
this.$nextTick(() => this.measure());
|
||||
},
|
||||
//The rail highlights, and the calendar opens on, whatever is being read
|
||||
currentEntryId: {
|
||||
immediate: true,
|
||||
@@ -278,7 +306,10 @@ export default {
|
||||
if(this.oPaginator.setMetrics(oMeasure, iLineHeight, iLines)) this.relayout();
|
||||
|
||||
this.bReady = true;
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
|
||||
//Re-measuring can move the caret onto a different spread, so the
|
||||
//caret is re-derived here rather than simply repainted where it was.
|
||||
this.$nextTick(() => this.syncCaret());
|
||||
},
|
||||
|
||||
//Geometry of the column the input and the measurer overlay
|
||||
@@ -444,6 +475,8 @@ export default {
|
||||
},
|
||||
|
||||
onInput(oEvent) {
|
||||
//Typing goes into the open entry, so the book has to be showing it
|
||||
this.bFollowCaret = true;
|
||||
this.journal.write(oEvent.target.value);
|
||||
this.relayout();
|
||||
this.syncCaret();
|
||||
@@ -461,12 +494,20 @@ export default {
|
||||
const oInput = this.$refs.input;
|
||||
if(!oInput) return;
|
||||
|
||||
//Read the focus rather than trusting the event: focusing an element
|
||||
//that already has the focus fires nothing, so on load - where the
|
||||
//book focuses itself twice - the flag would never be set and the
|
||||
//caret would stay hidden until the first keystroke.
|
||||
this.bFocused = (document.activeElement === oInput);
|
||||
|
||||
this.iSelectionStart = oInput.selectionStart;
|
||||
this.iSelectionEnd = oInput.selectionEnd;
|
||||
this.iCaretOffset = (oInput.selectionDirection === 'backward') ? oInput.selectionStart : oInput.selectionEnd;
|
||||
|
||||
//Following happens when the writing moves, not every time the caret
|
||||
//is re-read: a re-measure must not undo a page turn.
|
||||
const oPos = this.caretPosition;
|
||||
if(oPos && (oPos.spread !== this.iSpread)) this.setSpread(oPos.spread);
|
||||
if(this.bFollowCaret && this.bFocused && oPos && (oPos.spread !== this.iSpread)) this.setSpread(oPos.spread);
|
||||
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
@@ -475,6 +516,9 @@ export default {
|
||||
const oInput = this.$refs.input;
|
||||
if(!oInput) return;
|
||||
|
||||
//Putting the caret somewhere is asking to write there
|
||||
this.bFollowCaret = true;
|
||||
|
||||
oInput.focus({preventScroll: true});
|
||||
|
||||
if(bExtend) oInput.setSelectionRange(Math.min(oInput.selectionStart, iOffset), Math.max(oInput.selectionEnd, iOffset));
|
||||
@@ -585,38 +629,60 @@ export default {
|
||||
const sDir = sDirection || ((iNext > iFrom) ? 'forward' : 'back');
|
||||
const bForward = (sDir === 'forward');
|
||||
|
||||
//Moving to the facing spread is a page turn and looks like one. A
|
||||
//jump across the book - to a bookmark, a date, or the writing on
|
||||
//load - is not, so it does not pretend to be. (One page per view has
|
||||
//no gutter to hinge on; the template gives it a slide instead.)
|
||||
const bTurn = (Math.abs(iNext - iFrom) === 1);
|
||||
|
||||
//Keys the leaf element. Turning again before the last turn finished
|
||||
//has to build a new leaf, or Vue patches the old one in place and
|
||||
//the CSS animation carries on from wherever it had got to.
|
||||
this.iTurnId++;
|
||||
|
||||
if(this.iPagesPerView > 1) {
|
||||
this.asTurn = {
|
||||
this.asTurn = bTurn ? {
|
||||
id: this.iTurnId,
|
||||
dir: sDir,
|
||||
from: iFrom,
|
||||
//Front: the face you were reading. Back: what it reveals.
|
||||
front: bForward ? ((iFrom * 2) + 1) : (iFrom * 2),
|
||||
back: bForward ? (iNext * 2) : ((iNext * 2) + 1)
|
||||
};
|
||||
}
|
||||
else this.asTurn = {id: this.iTurnId, dir: sDir, from: iFrom, front: -1, back: -1};
|
||||
} : null;
|
||||
|
||||
this.iSpread = iNext;
|
||||
|
||||
clearTimeout(this.oTurnTimer);
|
||||
|
||||
if(bTurn) {
|
||||
this.oTurnTimer = setTimeout(() => {
|
||||
this.asTurn = null;
|
||||
|
||||
//The overlay must be redrawn once the leaf is gone: while it
|
||||
//was flying, one side of the spread was showing the page it
|
||||
//lifted from, so the caret had no page to be measured
|
||||
//against and was left undrawn.
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
}, TURN_MS);
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
|
||||
spreadShows(iEntryId) {
|
||||
return this.visiblePages.some((oPage) => oPage.lines.some((oLine) => oLine.id === iEntryId));
|
||||
},
|
||||
|
||||
sideFor(iPage) {
|
||||
return ((iPage % 2) === 0) ? 'left' : 'right';
|
||||
},
|
||||
|
||||
async turn(iDelta) {
|
||||
//Turning the page by hand means you are reading the book again, not
|
||||
//sitting on the entry you jumped to - and not being pulled back to
|
||||
//the writing by the next re-measure.
|
||||
this.iFocusId = 0;
|
||||
this.bFollowCaret = false;
|
||||
|
||||
//Turning past either edge of the loaded window fetches more book
|
||||
//before it turns, so the flow never dead-ends on a chunk boundary.
|
||||
if((iDelta < 0) && (this.iSpread === 0)) {
|
||||
@@ -676,6 +742,8 @@ export default {
|
||||
const oPos = this.asLayout.index.get(iEntryId);
|
||||
if(!oPos) return;
|
||||
|
||||
this.iFocusId = iEntryId;
|
||||
this.bFollowCaret = false;
|
||||
this.setSpread(Math.floor(oPos.firstPage / this.iPagesPerView));
|
||||
},
|
||||
|
||||
@@ -724,6 +792,16 @@ export default {
|
||||
@pick="onPick"
|
||||
/>
|
||||
|
||||
<!-- The shadow the leaf throws onto the page it is coming down
|
||||
onto, so that page reads as lying underneath rather than as
|
||||
a second column of text beside it. -->
|
||||
<span
|
||||
v-if="asTurn && (iPagesPerView > 1)"
|
||||
:key="'cast' + asTurn.id"
|
||||
class="book__cast"
|
||||
:class="'book__cast--' + asTurn.dir"
|
||||
></span>
|
||||
|
||||
<!-- The leaf in flight. Two real pages back to back, hinged on
|
||||
the gutter - the front is the page being lifted away, the
|
||||
back is the one it carries into view. -->
|
||||
|
||||
@@ -73,6 +73,19 @@ export default {
|
||||
this.$nextTick(() => this.scrollToCurrent());
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Walk the window one reach further back, by reading from its topmost
|
||||
* tab. At the top of what is loaded there is nothing to walk to, so this
|
||||
* fetches older entries instead.
|
||||
*/
|
||||
stepBack() {
|
||||
if(this.hasBefore) this.$emit('pick', this.shown[0].id);
|
||||
else this.$emit('load-older');
|
||||
},
|
||||
stepForward() {
|
||||
if(this.hasAfter) this.$emit('pick', this.shown.at(-1).id);
|
||||
},
|
||||
|
||||
scrollToCurrent() {
|
||||
const oList = this.$refs.list;
|
||||
if(!oList) return;
|
||||
@@ -89,14 +102,15 @@ export default {
|
||||
<nav class="rail" :aria-label="lang.get('book.bookmarks')">
|
||||
<span class="rail__title">{{ lang.get('book.bookmarks') }}</span>
|
||||
|
||||
<!-- There is more book above the window, or older entries still to load -->
|
||||
<!-- The book carries on above the window - or, at the top of what is
|
||||
loaded, there is more of it still to fetch. -->
|
||||
<button
|
||||
v-if="hasBefore || hasOlder"
|
||||
type="button"
|
||||
class="rail__more"
|
||||
class="rail__more rail__more--before"
|
||||
:disabled="loading"
|
||||
:title="lang.get('action.prev_page')"
|
||||
@click="$emit('load-older')"
|
||||
:title="lang.get('book.earlier')"
|
||||
@click="stepBack"
|
||||
>
|
||||
<appIcon icon="chevronLeft" size="0.8em" />
|
||||
</button>
|
||||
@@ -121,6 +135,16 @@ export default {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasAfter"
|
||||
type="button"
|
||||
class="rail__more rail__more--after"
|
||||
:title="lang.get('book.later')"
|
||||
@click="stepForward"
|
||||
>
|
||||
<appIcon icon="chevronRight" size="0.8em" />
|
||||
</button>
|
||||
|
||||
<span v-if="tabs.length === 0" class="rail__empty">{{ lang.get('book.first_page') }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { applyHand, loadHand } from '@scripts/hands';
|
||||
|
||||
/**
|
||||
* Account settings, saved one field at a time.
|
||||
@@ -35,6 +36,9 @@ export default {
|
||||
return [this.user.timezone].filter((sZone) => sZone !== '');
|
||||
}
|
||||
},
|
||||
hands() {
|
||||
return this.consts.hands || [];
|
||||
},
|
||||
languageNames() {
|
||||
const oNames = new Intl.DisplayNames([this.lang.locale], {type: 'language'});
|
||||
return this.languages.map((sCode) => ({
|
||||
@@ -44,6 +48,23 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Show the hand on the page as soon as it is picked, before the save
|
||||
* comes back - choosing a hand is a visual decision, and waiting on a
|
||||
* round trip to see it makes the picker feel broken.
|
||||
*/
|
||||
async pickHand(sHandId) {
|
||||
if(sHandId === this.user.hand) return;
|
||||
|
||||
await loadHand(this.hands, sHandId);
|
||||
applyHand(this.hands, sHandId);
|
||||
await this.set('hand', sHandId);
|
||||
|
||||
//The save is what settles it; if it failed, go back to the one the
|
||||
//account actually has rather than leaving a lie on screen.
|
||||
if(this.sError !== '') applyHand(this.hands, this.user.hand);
|
||||
},
|
||||
|
||||
async set(sField, sValue) {
|
||||
this.bBusy = true;
|
||||
this.sError = '';
|
||||
@@ -126,6 +147,29 @@ export default {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<span class="paper-label">{{ lang.get('account.hand') }}</span>
|
||||
<div class="hand-picker" role="radiogroup" :aria-label="lang.get('account.hand')">
|
||||
<button
|
||||
v-for="oHand in hands"
|
||||
:key="oHand.id"
|
||||
type="button"
|
||||
class="hand-picker__option"
|
||||
:class="(oHand.id === user.hand) ? 'hand-picker__option--on' : null"
|
||||
role="radio"
|
||||
:aria-checked="oHand.id === user.hand"
|
||||
:disabled="bBusy"
|
||||
:style="{fontFamily: oHand.family + ', cursive'}"
|
||||
@click="pickHand(oHand.id)"
|
||||
>
|
||||
<!-- Written in itself: the only thing worth knowing about
|
||||
a hand is what your words look like in it. -->
|
||||
<span class="hand-picker__sample">{{ lang.get('account.hand_sample') }}</span>
|
||||
<span class="hand-picker__name">{{ oHand.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="set-tz">{{ lang.get('account.timezone') }}</label>
|
||||
<select
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* The webfonts for every hand the book can be written in.
|
||||
*
|
||||
* Side-effect imports only: these declare @font-face rules and nothing more.
|
||||
* A declared face costs nothing until something is rendered in it, so shipping
|
||||
* all six is a few KB of CSS - the woff2 files are fetched only for the hand
|
||||
* actually in use, plus whatever the settings picker previews when it is opened.
|
||||
*
|
||||
* The list itself lives in MyThoughts::HANDS on the server, which is also what
|
||||
* validates a change - adding one there means adding its import here.
|
||||
*/
|
||||
import '@fontsource-variable/caveat/index.css';
|
||||
import '@fontsource/kalam/400.css';
|
||||
import '@fontsource/patrick-hand/400.css';
|
||||
import '@fontsource/shadows-into-light/400.css';
|
||||
import '@fontsource/architects-daughter/400.css';
|
||||
import '@fontsource/indie-flower/400.css';
|
||||
|
||||
//Fallbacks appended to whichever hand is chosen, so the book still reads as
|
||||
//handwriting if a webfont fails
|
||||
const FALLBACK = '"Segoe Script", cursive';
|
||||
|
||||
/**
|
||||
* Put a hand on the page.
|
||||
*
|
||||
* Both values are set together and read by the book's type metrics: the family
|
||||
* and the size that makes that family sit on the ruled lines. Setting one
|
||||
* without the other gives writing that floats above the rule or crashes
|
||||
* through it.
|
||||
*/
|
||||
export function applyHand(asHands, sHandId) {
|
||||
const oHand = asHands.find((oItem) => oItem.id === sHandId) || asHands[0];
|
||||
if(!oHand) return null;
|
||||
|
||||
const oRoot = document.documentElement;
|
||||
oRoot.style.setProperty('--book-hand', oHand.family + ', ' + FALLBACK);
|
||||
oRoot.style.setProperty('--book-hand-scale', String(oHand.scale));
|
||||
|
||||
return oHand;
|
||||
}
|
||||
|
||||
/** Wait for a hand's files to arrive, so the book is not measured in the fallback */
|
||||
export async function loadHand(asHands, sHandId, sSizeSample = '16px') {
|
||||
const oHand = asHands.find((oItem) => oItem.id === sHandId);
|
||||
if(!oHand || !document.fonts?.load) return;
|
||||
|
||||
try {
|
||||
await document.fonts.load(sSizeSample + ' ' + oHand.family.split(',')[0].trim());
|
||||
}
|
||||
catch{
|
||||
//A hand that will not load is not worth failing the page over - the
|
||||
//fallback stack still renders, and the next measure picks up the real
|
||||
//font whenever it does arrive.
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,19 @@ export default class Paginator {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away every measurement taken so far.
|
||||
*
|
||||
* For a change of font, where setMetrics() cannot help: the column width,
|
||||
* the line height and the lines per page can all come back identical, so
|
||||
* the signature says nothing has changed - while every line in the book now
|
||||
* breaks somewhere else.
|
||||
*/
|
||||
reset() {
|
||||
this.asCache.clear();
|
||||
this.sSignature = '';
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
/**
|
||||
|
||||
+112
-9
@@ -213,7 +213,7 @@
|
||||
* three of them take their typography from exactly one place: here. */
|
||||
@mixin page-text-metrics {
|
||||
font-family: var.$font-hand;
|
||||
font-size: calc(var(--book-line) * 0.82);
|
||||
font-size: calc(var(--book-line) * #{var.$hand-scale});
|
||||
line-height: var(--book-line);
|
||||
letter-spacing: 0.005em;
|
||||
word-spacing: 0.02em;
|
||||
@@ -410,13 +410,13 @@
|
||||
.book__leaf--forward {
|
||||
left: 50%;
|
||||
transform-origin: left center;
|
||||
animation: leaf-forward var.$trans-slow cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
animation: leaf-forward var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
.book__leaf--back {
|
||||
left: 0;
|
||||
transform-origin: right center;
|
||||
animation: leaf-back var.$trans-slow cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
animation: leaf-back var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
//Right page sweeping left across the gutter
|
||||
@@ -447,7 +447,7 @@
|
||||
overflow: hidden;
|
||||
backface-visibility: hidden;
|
||||
background-color: color.$paper;
|
||||
box-shadow: 0 0 2rem rgba(20, 14, 9, 0.35);
|
||||
box-shadow: 0 0 2.4rem rgba(20, 14, 9, 0.5), 0 0 0 1px rgba(120, 96, 62, 0.18);
|
||||
|
||||
//The page fills the leaf rather than half a spread
|
||||
.page {
|
||||
@@ -461,6 +461,47 @@
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* Which face you can see is switched explicitly at the upright crossing,
|
||||
* rather than left to `backface-visibility` alone.
|
||||
*
|
||||
* That property is the right tool and is set above, but engines disagree about
|
||||
* it once `preserve-3d` and a clipped ancestor are involved - and when it is
|
||||
* ignored the front face shows straight through the back one, putting both
|
||||
* pages' words on top of each other for the length of the turn. The switch
|
||||
* point is where the leaf passes 90 degrees under the easing above (measured,
|
||||
* not guessed - the rotation is not linear in time). */
|
||||
.book__leaf-face--front {
|
||||
animation: leaf-face-front var.$turn linear forwards;
|
||||
}
|
||||
|
||||
.book__leaf-face--back {
|
||||
animation: leaf-face-back var.$turn linear forwards;
|
||||
}
|
||||
|
||||
@keyframes leaf-face-front {
|
||||
0%,
|
||||
38% {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
39%,
|
||||
100% {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leaf-face-back {
|
||||
0%,
|
||||
38% {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
39%,
|
||||
100% {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Paper catches the light as it lifts and loses it as it lands. Two passes of
|
||||
* the same shade, offset by half the turn, is what sells the leaf as solid. */
|
||||
.book__leaf-shade {
|
||||
@@ -471,12 +512,12 @@
|
||||
|
||||
.book__leaf-face--front .book__leaf-shade {
|
||||
background-image: linear-gradient(to left, rgba(20, 14, 9, 0.42), rgba(20, 14, 9, 0.04) 45%, transparent 75%);
|
||||
animation: leaf-shade-out var.$trans-slow ease-in forwards;
|
||||
animation: leaf-shade-out var.$turn ease-in forwards;
|
||||
}
|
||||
|
||||
.book__leaf-face--back .book__leaf-shade {
|
||||
background-image: linear-gradient(to right, rgba(20, 14, 9, 0.46), rgba(20, 14, 9, 0.06) 45%, transparent 75%);
|
||||
animation: leaf-shade-in var.$trans-slow ease-out forwards;
|
||||
animation: leaf-shade-in var.$turn ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes leaf-shade-out {
|
||||
@@ -499,9 +540,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The shadow a leaf casts on the page it is coming down onto.
|
||||
*
|
||||
* This is what stops the page underneath reading as a second page of text
|
||||
* beside the leaf: real paper coming over a page darkens it, and the eye uses
|
||||
* that to tell which sheet is on top. Sits above the pages and below the leaf.
|
||||
*/
|
||||
.book__cast {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.book__cast--forward {
|
||||
left: 0;
|
||||
background-image: linear-gradient(to left, rgba(24, 17, 10, 0.62), rgba(24, 17, 10, 0.34) 45%, rgba(24, 17, 10, 0.16) 100%);
|
||||
animation: cast-forward var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
.book__cast--back {
|
||||
left: 50%;
|
||||
background-image: linear-gradient(to right, rgba(24, 17, 10, 0.62), rgba(24, 17, 10, 0.34) 45%, rgba(24, 17, 10, 0.16) 100%);
|
||||
animation: cast-back var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
/* Deepens from the moment the leaf starts to lift - a raised sheet shades the
|
||||
* spread under it - holds through the crossing, where the page being left and
|
||||
* the page being uncovered are both in view, and lifts once the leaf has landed
|
||||
* and is itself the page being read. */
|
||||
@keyframes cast-forward {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cast-back {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The gutter darkens under a leaf that is standing up over it */
|
||||
.book__turning .book__spine {
|
||||
animation: gutter-deepen var.$trans-slow ease-in-out;
|
||||
animation: gutter-deepen var.$turn ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes gutter-deepen {
|
||||
@@ -518,11 +621,11 @@
|
||||
/* One page per view has no gutter to hinge on, so the page is dealt off the
|
||||
* pile in the direction of travel instead of pretending to be bound. */
|
||||
.book__spread--single.book__turning--forward .page {
|
||||
animation: page-in-from-right var.$trans-mid ease-out;
|
||||
animation: page-in-from-right var.$turn ease-out;
|
||||
}
|
||||
|
||||
.book__spread--single.book__turning--back .page {
|
||||
animation: page-in-from-left var.$trans-mid ease-out;
|
||||
animation: page-in-from-left var.$turn ease-out;
|
||||
}
|
||||
|
||||
@keyframes page-in-from-right {
|
||||
|
||||
@@ -185,3 +185,66 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Picking the hand the book is written in.
|
||||
*
|
||||
* Each option is set in the hand it offers, because the name of a typeface
|
||||
* tells you nothing - what your own words look like in it is the whole
|
||||
* decision. The sample line comes from the dictionary, so it reads as a
|
||||
* sentence rather than as "Aa Bb Cc" in whatever language.
|
||||
*/
|
||||
.hand-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hand-picker__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid color.$rule;
|
||||
border-radius: var.$block-radius;
|
||||
background-color: color.$paper;
|
||||
color: color.$ink;
|
||||
transition: border-color var.$trans-quick, background-color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color.$ink-faint;
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
//The one in use: a ruled line under the sample, like the page itself
|
||||
.hand-picker__option--on {
|
||||
border-color: color.$caramel;
|
||||
background-color: color.$paper-shade;
|
||||
box-shadow: inset 0 0 0 1px color.$caramel;
|
||||
}
|
||||
|
||||
.hand-picker__sample {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
//The name is the only part not set in the hand - it is a label, not a sample
|
||||
.hand-picker__name {
|
||||
font-family: var.$font-ui;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
//The window is small and centred on where the reading is, so the current
|
||||
//tab sits at eye level rather than wherever a full list would put it.
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0;
|
||||
|
||||
@@ -144,8 +147,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* "Load older entries" sits at the top of the rail, since the rail is
|
||||
* chronological and the oldest entry is the first tab. */
|
||||
/* The rail shows a few tabs either side of where the reading is. These two
|
||||
* say the book carries on past them, and walk the window along. */
|
||||
.rail__more {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
|
||||
+12
-1
@@ -29,7 +29,13 @@ $tab-height: 3.1rem;
|
||||
$tab-tuck: 1.4rem;
|
||||
|
||||
//Typography
|
||||
$font-hand: "Caveat Variable", "Caveat", "Segoe Script", cursive;
|
||||
//The hand the book is written in, chosen in settings and set on :root by
|
||||
//hands.js. The literal here is only what shows before that runs.
|
||||
$font-hand: var(--book-hand, "Caveat Variable", "Caveat", "Segoe Script", cursive);
|
||||
|
||||
//Type size as a fraction of one ruled line. Travels with the hand, since each
|
||||
//has its own x-height for the same em.
|
||||
$hand-scale: var(--book-hand-scale, 0.82);
|
||||
$font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
//Transitions
|
||||
@@ -37,6 +43,11 @@ $trans-quick: 160ms;
|
||||
$trans-mid: 280ms;
|
||||
$trans-slow: 600ms;
|
||||
|
||||
//A page turn. Short on purpose: while a leaf is upright both the page it left
|
||||
//and the page it is landing on are legible at once, and the longer that lasts
|
||||
//the more it reads as two pages muddled together rather than one turning.
|
||||
$turn: 430ms;
|
||||
|
||||
//Elevation
|
||||
$shadow-page: 0 1.5rem 3rem color.$shadow-deep;
|
||||
$shadow-panel: 0 0.75rem 2rem color.$shadow-deep;
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
/* The hand the journal is written in. Only the variable face is pulled in -
|
||||
* the paginator measures whatever is actually loaded, so the font has to be
|
||||
* settled (document.fonts.ready) before the first layout, not merely linked. */
|
||||
@import '@fontsource-variable/caveat/index.css';
|
||||
|
||||
Reference in New Issue
Block a user