diff --git a/lib/Journal.php b/lib/Journal.php
index 2f0d26e..8ec2a31 100644
--- a/lib/Journal.php
+++ b/lib/Journal.php
@@ -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 {
diff --git a/lib/MyThoughts.php b/lib/MyThoughts.php
index ecb69a2..92ecf42 100644
--- a/lib/MyThoughts.php
+++ b/lib/MyThoughts.php
@@ -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
diff --git a/lib/User.php b/lib/User.php
index e134879..0b543c8 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -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');
diff --git a/package-lock.json b/package-lock.json
index 7167a94..da7031e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b386ded..4d38e28 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/resources/lang/en.json b/resources/lang/en.json
index d1d01fe..f753007 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -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",
diff --git a/resources/lang/fr.json b/resources/lang/fr.json
index 442dd0f..e8e4b97 100644
--- a/resources/lang/fr.json
+++ b/resources/lang/fr.json
@@ -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",
diff --git a/src/app.js b/src/app.js
index e7ebb03..31c1514 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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
diff --git a/src/components/Book.vue b/src/components/Book.vue
index 3713dfb..855b723 100644
--- a/src/components/Book.vue
+++ b/src/components/Book.vue
@@ -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 = {
- 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};
+ 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)
+ } : null;
this.iSpread = iNext;
clearTimeout(this.oTurnTimer);
- this.oTurnTimer = setTimeout(() => {
- this.asTurn = null;
- }, TURN_MS);
+
+ 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"
/>
+
+
+
diff --git a/src/components/BookmarkRail.vue b/src/components/BookmarkRail.vue
index f1cce8e..1de43c8 100644
--- a/src/components/BookmarkRail.vue
+++ b/src/components/BookmarkRail.vue
@@ -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 {
diff --git a/src/components/SettingsPanel.vue b/src/components/SettingsPanel.vue
index de4e06e..96e0a9e 100644
--- a/src/components/SettingsPanel.vue
+++ b/src/components/SettingsPanel.vue
@@ -1,5 +1,6 @@