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 @@