v3 init push
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import authPanel from '@components/AuthPanel';
|
||||
import book from '@components/Book';
|
||||
import bookmarkRail from '@components/BookmarkRail';
|
||||
import calendarWidget from '@components/CalendarWidget';
|
||||
import saveIndicator from '@components/SaveIndicator';
|
||||
import settingsPanel from '@components/SettingsPanel';
|
||||
import sLogo from '@images/logo.png';
|
||||
|
||||
/**
|
||||
* The desk: the book, the tabs down its side, and the few controls that are not
|
||||
* part of the book itself.
|
||||
*
|
||||
* This also owns the one piece of lifecycle that cannot live in a component:
|
||||
* sealing the open entry when the page goes away. It has to be a beacon - a
|
||||
* fetch issued during unload is cancelled along with the document - and it
|
||||
* closes the entry only, never the login.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon,
|
||||
authPanel,
|
||||
book,
|
||||
bookmarkRail,
|
||||
calendarWidget,
|
||||
saveIndicator,
|
||||
settingsPanel
|
||||
},
|
||||
inject: ['api', 'consts', 'journal', 'lang', 'user'],
|
||||
data() {
|
||||
return {
|
||||
sLogo,
|
||||
bLoaded: false,
|
||||
bCalendarOpen: false,
|
||||
bSettingsOpen: false,
|
||||
bMenuOpen: false,
|
||||
bRailOpen: false,
|
||||
iCurrentId: 0,
|
||||
sCurrentDay: '',
|
||||
sNotice: '',
|
||||
bNoticeBad: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
signedIn() {
|
||||
return (this.user.id > 0);
|
||||
},
|
||||
days() {
|
||||
return this.journal.days;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
signedIn: {
|
||||
immediate: true,
|
||||
handler(bSignedIn) {
|
||||
if(bSignedIn) this.openBook();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//pagehide is the one that fires reliably on mobile; beforeunload does
|
||||
//not on iOS, and visibilitychange fires on every tab switch.
|
||||
window.addEventListener('pagehide', this.onLeave);
|
||||
window.addEventListener('beforeunload', this.onLeave);
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
document.addEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('pagehide', this.onLeave);
|
||||
window.removeEventListener('beforeunload', this.onLeave);
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
document.removeEventListener('keydown', this.onKeydown);
|
||||
},
|
||||
methods: {
|
||||
async openBook() {
|
||||
try {
|
||||
await this.journal.load();
|
||||
|
||||
//Arriving at the book should mean you can just write - so the
|
||||
//page being written on is claimed up front. An entry nothing was
|
||||
//written in is discarded when it closes, so this costs nothing.
|
||||
if(this.journal.openId === 0) await this.journal.startWriting();
|
||||
|
||||
this.bLoaded = true;
|
||||
this.$nextTick(() => this.$refs.book?.goToWriting());
|
||||
}
|
||||
catch(oError) {
|
||||
this.notify(oError.desc_lang_text || oError.message, true);
|
||||
}
|
||||
},
|
||||
|
||||
onReading({id, day}) {
|
||||
this.iCurrentId = id;
|
||||
this.sCurrentDay = day;
|
||||
},
|
||||
|
||||
/* Getting about */
|
||||
|
||||
async onPickBookmark(iEntryId) {
|
||||
this.bRailOpen = false;
|
||||
await this.$refs.book?.goToEntry(iEntryId);
|
||||
},
|
||||
|
||||
async onPickDate(sDay) {
|
||||
this.bCalendarOpen = false;
|
||||
|
||||
try {
|
||||
const iEntryId = await this.journal.findEntryAtDate(sDay);
|
||||
if(iEntryId > 0) await this.$refs.book?.goToEntry(iEntryId);
|
||||
}
|
||||
catch(oError) {
|
||||
this.notify(oError.desc_lang_text || oError.message, true);
|
||||
}
|
||||
},
|
||||
|
||||
async onLoadOlder() {
|
||||
try {
|
||||
await this.$refs.book?.extendBackwards();
|
||||
}
|
||||
catch(oError) {
|
||||
this.notify(oError.desc_lang_text || oError.message, true);
|
||||
}
|
||||
},
|
||||
|
||||
goToWriting() {
|
||||
this.$refs.book?.goToWriting();
|
||||
},
|
||||
|
||||
/* Account */
|
||||
|
||||
onSignedIn(asUser) {
|
||||
Object.assign(this.user, asUser);
|
||||
},
|
||||
|
||||
async onSignedOut() {
|
||||
this.bSettingsOpen = false;
|
||||
this.bMenuOpen = false;
|
||||
|
||||
//Everything about the previous book has to go, or the next reader
|
||||
//would be handed its pages.
|
||||
this.journal.entries = [];
|
||||
this.journal.bookmarks = [];
|
||||
this.journal.openId = 0;
|
||||
this.bLoaded = false;
|
||||
|
||||
Object.assign(this.user, {id: 0, name: '', email: ''});
|
||||
},
|
||||
|
||||
/* Leaving */
|
||||
|
||||
onLeave() {
|
||||
//Whatever is on the page stays as an entry; the login is untouched.
|
||||
this.journal.closeOnUnload();
|
||||
},
|
||||
|
||||
onVisibilityChange() {
|
||||
//A backgrounded tab may never come back, so flush before it goes.
|
||||
if(document.visibilityState === 'hidden' && this.journal.isDirty) this.journal.save();
|
||||
},
|
||||
|
||||
onKeydown(oEvent) {
|
||||
if(oEvent.key !== 'Escape') return;
|
||||
|
||||
if(this.bCalendarOpen) this.bCalendarOpen = false;
|
||||
else if(this.bMenuOpen) this.bMenuOpen = false;
|
||||
else if(this.bRailOpen) this.bRailOpen = false;
|
||||
},
|
||||
|
||||
notify(sMessage, bBad = false) {
|
||||
this.sNotice = sMessage;
|
||||
this.bNoticeBad = bBad;
|
||||
},
|
||||
|
||||
closeMenus() {
|
||||
this.bCalendarOpen = false;
|
||||
this.bMenuOpen = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app__header">
|
||||
<h1 class="app__brand">
|
||||
<img class="app__logo" :src="sLogo" :alt="consts.title" />
|
||||
<span class="app__tagline">{{ lang.get('book.tagline') }}</span>
|
||||
</h1>
|
||||
|
||||
<div class="app__tools">
|
||||
<saveIndicator v-if="signedIn && bLoaded" />
|
||||
|
||||
<template v-if="signedIn">
|
||||
<button
|
||||
type="button"
|
||||
class="desk-button"
|
||||
:title="lang.get('book.go_to_writing')"
|
||||
@click="goToWriting"
|
||||
>
|
||||
<appIcon icon="pen" />
|
||||
</button>
|
||||
|
||||
<div class="app__popover-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="desk-button"
|
||||
:aria-expanded="bCalendarOpen"
|
||||
:title="lang.get('action.calendar')"
|
||||
@click="bCalendarOpen = !bCalendarOpen; bMenuOpen = false"
|
||||
>
|
||||
<appIcon icon="calendar" />
|
||||
</button>
|
||||
|
||||
<div v-if="bCalendarOpen" class="app__popover">
|
||||
<calendarWidget
|
||||
:days="days"
|
||||
:current-day="sCurrentDay"
|
||||
@pick="onPickDate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="desk-button desk-button--icon app__rail-toggle"
|
||||
:title="lang.get('book.bookmarks')"
|
||||
@click="bRailOpen = !bRailOpen"
|
||||
>
|
||||
<appIcon icon="bookmark" />
|
||||
</button>
|
||||
|
||||
<div class="app__popover-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="desk-button"
|
||||
:aria-expanded="bMenuOpen"
|
||||
:title="lang.get('account.settings')"
|
||||
@click="bMenuOpen = !bMenuOpen; bCalendarOpen = false"
|
||||
>
|
||||
<appIcon icon="user" />
|
||||
</button>
|
||||
|
||||
<div v-if="bMenuOpen" class="app__popover account-menu">
|
||||
<div class="account-menu__who">
|
||||
<div class="account-menu__name">{{ user.name }}</div>
|
||||
<div class="account-menu__email">{{ user.email }}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="account-menu__item"
|
||||
@click="bSettingsOpen = true; bMenuOpen = false"
|
||||
>
|
||||
<appIcon icon="settings" />
|
||||
<span>{{ lang.get('account.settings') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app__desk" @click="closeMenus">
|
||||
<div class="app__book">
|
||||
<book
|
||||
v-if="signedIn && bLoaded"
|
||||
ref="book"
|
||||
@reading="onReading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="signedIn && bLoaded"
|
||||
class="app__rail"
|
||||
:class="bRailOpen ? 'app__rail--open' : null"
|
||||
>
|
||||
<bookmarkRail
|
||||
:bookmarks="journal.bookmarks"
|
||||
:current-id="iCurrentId"
|
||||
:open-id="journal.openId"
|
||||
:has-older="journal.hasOlder"
|
||||
:loading="journal.loading"
|
||||
@pick="onPickBookmark"
|
||||
@load-older="onLoadOlder"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div v-if="sNotice" class="app__notice" :class="bNoticeBad ? 'app__notice--bad' : null" role="status">
|
||||
<appIcon :icon="bNoticeBad ? 'alert' : 'check'" />
|
||||
<span>{{ sNotice }}</span>
|
||||
<button type="button" class="app__notice-close" @click="sNotice = ''">
|
||||
<appIcon icon="close" size="0.9em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<authPanel v-if="!signedIn" @done="onSignedIn" />
|
||||
|
||||
<settingsPanel
|
||||
v-if="bSettingsOpen"
|
||||
@close="bSettingsOpen = false"
|
||||
@signed-out="onSignedOut"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//Librairies
|
||||
import 'vite/modulepreload-polyfill';
|
||||
import Api from '@scripts/api';
|
||||
import Journal from '@scripts/journal';
|
||||
import Lang from '@scripts/lang';
|
||||
import { getBrowserTimezone } from '@scripts/time';
|
||||
import { createApp, reactive } from 'vue';
|
||||
|
||||
//Main template
|
||||
import App from './App.vue';
|
||||
|
||||
//Style
|
||||
import '@styles/mythoughts.scss';
|
||||
|
||||
//App Configuration from PHP
|
||||
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
|
||||
|
||||
//Instances
|
||||
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
|
||||
|
||||
//The locale lives in the dictionary rather than in a const, so a new language
|
||||
//file brings its own date formatting with it.
|
||||
oLang.locale = (oLang.get('meta.locale') || 'en').replace('_', '-');
|
||||
|
||||
//The browser's zone is the one thing PHP cannot know before the first request,
|
||||
//so every call carries it and the server formats no dates at all.
|
||||
const sTimezone = getBrowserTimezone() || appConfig.user.timezone || appConfig.consts.default_timezone;
|
||||
|
||||
const oApi = new Api({
|
||||
server: appConfig.consts.server,
|
||||
processPage: appConfig.consts.process_page,
|
||||
timezone: sTimezone,
|
||||
csrfToken: appConfig.consts.csrf_token,
|
||||
errorCode: appConfig.consts.error,
|
||||
lang: oLang
|
||||
});
|
||||
|
||||
const oUser = reactive({...appConfig.user});
|
||||
const oJournal = reactive(new Journal(oApi, appConfig.consts));
|
||||
|
||||
//Mount app
|
||||
const oApp = createApp(App);
|
||||
oApp.provide('api', oApi);
|
||||
oApp.provide('consts', appConfig.consts);
|
||||
oApp.provide('journal', oJournal);
|
||||
oApp.provide('lang', oLang);
|
||||
oApp.provide('timezone', sTimezone);
|
||||
oApp.provide('user', oUser);
|
||||
oApp.mount('#container');
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import { getIconPaths, hasIcon } from '@scripts/icons';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
size: {type: String, default: '1.15em'},
|
||||
title: String
|
||||
},
|
||||
computed: {
|
||||
paths() {
|
||||
if(!hasIcon(this.icon)) console.warn('Missing icon:', this.icon);
|
||||
return getIconPaths(this.icon);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="app-icon"
|
||||
:class="'app-icon--' + icon"
|
||||
:style="{width: size, height: size}"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:role="title ? 'img' : 'presentation'"
|
||||
:aria-hidden="title ? null : 'true'"
|
||||
>
|
||||
<title v-if="title">{{ title }}</title>
|
||||
<path v-for="(sPath, iIndex) in paths" :key="iIndex" :d="sPath" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.app-icon {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import sLogo from '@images/logo.png';
|
||||
|
||||
/**
|
||||
* The closed book: sign in, or start a new one.
|
||||
*
|
||||
* The two modes are one form because they differ by a single field - splitting
|
||||
* them into separate components would duplicate the whole submit path for the
|
||||
* sake of a name input.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon
|
||||
},
|
||||
emits: ['done'],
|
||||
inject: ['api', 'consts', 'lang', 'timezone'],
|
||||
data() {
|
||||
return {
|
||||
sLogo,
|
||||
bNewBook: false,
|
||||
sName: '',
|
||||
sEmail: '',
|
||||
sPassword: '',
|
||||
bRemember: true,
|
||||
sError: '',
|
||||
bBusy: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canSubmit() {
|
||||
return !this.bBusy && (this.sEmail.trim() !== '') && (this.sPassword !== '') && (!this.bNewBook || (this.sName.trim() !== ''));
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.first?.focus();
|
||||
},
|
||||
methods: {
|
||||
toggleMode() {
|
||||
this.bNewBook = !this.bNewBook;
|
||||
this.sError = '';
|
||||
this.$nextTick(() => this.$refs.first?.focus());
|
||||
},
|
||||
async submit() {
|
||||
if(!this.canSubmit) return;
|
||||
|
||||
this.bBusy = true;
|
||||
this.sError = '';
|
||||
|
||||
try {
|
||||
const asData = this.bNewBook ?
|
||||
await this.api.post('signup', {
|
||||
name: this.sName,
|
||||
email: this.sEmail,
|
||||
password: this.sPassword,
|
||||
t: this.timezone
|
||||
})
|
||||
:
|
||||
await this.api.post('login', {
|
||||
email: this.sEmail,
|
||||
password: this.sPassword,
|
||||
remember: this.bRemember ? 1 : 0,
|
||||
t: this.timezone
|
||||
});
|
||||
|
||||
//Nothing typed here should outlive the successful submit.
|
||||
this.sPassword = '';
|
||||
this.$emit('done', asData.user);
|
||||
}
|
||||
catch(oError) {
|
||||
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
|
||||
this.sPassword = '';
|
||||
this.$refs.password?.focus();
|
||||
}
|
||||
finally {
|
||||
this.bBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="veil">
|
||||
<form class="leaf" @submit.prevent="submit">
|
||||
<div class="leaf__head">
|
||||
<!-- Paper at last: the logo's own colours, as it was drawn -->
|
||||
<img class="leaf__logo" :src="sLogo" alt="MyThoughts" />
|
||||
<h2 class="leaf__title">
|
||||
{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}
|
||||
</h2>
|
||||
<p class="leaf__sub">{{ lang.get('book.tagline') }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
|
||||
|
||||
<div v-if="bNewBook" class="leaf__row">
|
||||
<label class="paper-label" for="auth-name">{{ lang.get('account.name') }}</label>
|
||||
<input
|
||||
id="auth-name"
|
||||
ref="first"
|
||||
v-model="sName"
|
||||
class="paper-field"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
maxlength="100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="auth-email">{{ lang.get('account.email') }}</label>
|
||||
<input
|
||||
id="auth-email"
|
||||
:ref="bNewBook ? null : 'first'"
|
||||
v-model="sEmail"
|
||||
class="paper-field"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="auth-password">{{ lang.get('account.password') }}</label>
|
||||
<input
|
||||
id="auth-password"
|
||||
ref="password"
|
||||
v-model="sPassword"
|
||||
class="paper-field"
|
||||
type="password"
|
||||
:autocomplete="bNewBook ? 'new-password' : 'current-password'"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label v-if="!bNewBook" class="leaf__check">
|
||||
<input v-model="bRemember" type="checkbox" />
|
||||
<span>{{ lang.get('account.remember') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="leaf__actions">
|
||||
<button type="submit" class="ink-button" :disabled="!canSubmit">
|
||||
<appIcon :icon="bNewBook ? 'pen' : 'book'" />
|
||||
<span>{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="leaf__note">
|
||||
<button type="button" class="text-button" @click="toggleMode">
|
||||
{{ bNewBook ? lang.get('account.have_account') : lang.get('account.no_account') }}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,807 @@
|
||||
<script>
|
||||
import bookPage from '@components/BookPage';
|
||||
import appIcon from '@components/AppIcon';
|
||||
import Paginator from '@scripts/paginator';
|
||||
import { getDayKey } from '@scripts/time';
|
||||
|
||||
//Matches $mobile in _var.scss: below this a spread cannot hold two pages, so
|
||||
//the book paginates one page per view instead.
|
||||
const SINGLE_PAGE_WIDTH = 860;
|
||||
|
||||
const TURN_MS = 700;
|
||||
|
||||
/**
|
||||
* The open book.
|
||||
*
|
||||
* Three things meet here and nowhere else:
|
||||
*
|
||||
* - Measurement. One hidden element, laid out by the browser at the exact width
|
||||
* of a real text column, decides where every line breaks. Nothing guesses at
|
||||
* text metrics.
|
||||
* - Flow. The paginator turns the journal into visual lines, the lines into
|
||||
* pages, and the pages into spreads - so an entry runs off the left page onto
|
||||
* the right one and on into the next spread, and a new entry simply starts on
|
||||
* the line after the last one ended.
|
||||
* - Writing. A textarea cannot flow across two columns, so it is not what you
|
||||
* look at: it holds the keystrokes, the selection and the arrow keys, while
|
||||
* the book draws the glyphs, the caret and the selection itself. It is sized
|
||||
* to exactly one column, which makes its own soft wrapping agree with the
|
||||
* paginator's - so Up and Down still move by the lines you can see.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon,
|
||||
bookPage
|
||||
},
|
||||
emits: ['reading'],
|
||||
inject: ['journal', 'lang'],
|
||||
data() {
|
||||
return {
|
||||
iSpread: 0,
|
||||
iPagesPerView: 2,
|
||||
asLayout: {pages: [[]], index: new Map(), lineCount: 0, linesPerPage: 1},
|
||||
iLineHeight: 0,
|
||||
iLinesPerPage: 0,
|
||||
|
||||
//Geometry of the text column the input and the measurer sit on,
|
||||
//relative to the spread
|
||||
asTextBox: {left: 0, top: 0, width: 0, height: 0},
|
||||
|
||||
iCaretOffset: 0,
|
||||
iSelectionStart: 0,
|
||||
iSelectionEnd: 0,
|
||||
bFocused: false,
|
||||
|
||||
asCaret: null,
|
||||
asSelection: [],
|
||||
|
||||
//The leaf in flight: {id, dir, front, back, from} while a page turns
|
||||
asTurn: null,
|
||||
iTurnId: 0,
|
||||
bReady: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
entriesById() {
|
||||
const asEntries = new Map();
|
||||
for(const oEntry of this.journal.entries) asEntries.set(oEntry.id, oEntry);
|
||||
return asEntries;
|
||||
},
|
||||
pages() {
|
||||
const aoPages = this.asLayout.pages || [[]];
|
||||
|
||||
//The paginator pads to an even number of pages so a spread is always
|
||||
//full. One page per view has no facing page to fill, so the padding
|
||||
//would just be a blank screen at the end of the book.
|
||||
if((this.iPagesPerView === 1) && (aoPages.length > 1) && (aoPages.at(-1).length === 0)) {
|
||||
return aoPages.slice(0, -1);
|
||||
}
|
||||
|
||||
return aoPages;
|
||||
},
|
||||
//Pages are uniform chunks, so a flat line index maps straight back to a
|
||||
//page - which is what makes "where is the caret" a lookup, not a walk.
|
||||
flatLines() {
|
||||
return this.pages.flat();
|
||||
},
|
||||
spreadCount() {
|
||||
return Math.max(1, Math.ceil(this.pages.length / this.iPagesPerView));
|
||||
},
|
||||
visiblePages() {
|
||||
const iFirst = this.iSpread * this.iPagesPerView;
|
||||
const aoVisible = [];
|
||||
|
||||
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
|
||||
const iPage = iFirst + iOffset;
|
||||
aoVisible.push({index: iPage, lines: this.pages[iPage] || []});
|
||||
}
|
||||
|
||||
//Mid-turn, the half the leaf lifted from still shows the page it
|
||||
//lifted from - it is only covered when the leaf lands on it. So the
|
||||
//spread underneath is a mix: one side already turned, one not yet.
|
||||
if(this.asTurn && (this.iPagesPerView > 1)) {
|
||||
const iStale = (this.asTurn.dir === 'forward') ? 0 : 1;
|
||||
const iStalePage = (this.asTurn.from * this.iPagesPerView) + iStale;
|
||||
aoVisible[iStale] = {index: iStalePage, lines: this.pages[iStalePage] || []};
|
||||
}
|
||||
|
||||
return aoVisible;
|
||||
},
|
||||
openEntry() {
|
||||
return this.journal.openEntry;
|
||||
},
|
||||
entryCount() {
|
||||
return this.journal.entries.length;
|
||||
},
|
||||
caretPosition() {
|
||||
const oOpen = this.openEntry;
|
||||
if(!oOpen) return null;
|
||||
|
||||
const iOffset = Math.max(0, Math.min(this.iCaretOffset, (oOpen.content || '').length));
|
||||
const asLines = this.flatLines;
|
||||
let iFound = -1;
|
||||
|
||||
for(let iLine = 0; iLine < asLines.length; iLine++) {
|
||||
const oLine = asLines[iLine];
|
||||
if(oLine.id !== oOpen.id) continue;
|
||||
|
||||
//Lines of the open entry are contiguous and in order, so the
|
||||
//last one starting at or before the caret is the caret's line.
|
||||
if(oLine.start <= iOffset) iFound = iLine;
|
||||
else break;
|
||||
}
|
||||
|
||||
if(iFound < 0) return null;
|
||||
|
||||
const iPerPage = Math.max(1, this.asLayout.linesPerPage);
|
||||
const iPage = Math.floor(iFound / iPerPage);
|
||||
|
||||
return {
|
||||
global: iFound,
|
||||
page: iPage,
|
||||
line: iFound % iPerPage,
|
||||
spread: Math.floor(iPage / this.iPagesPerView),
|
||||
offset: iOffset
|
||||
};
|
||||
},
|
||||
//What the rail highlights and the calendar opens on: the entry the
|
||||
//visible spread starts with.
|
||||
currentEntryId() {
|
||||
for(const oPage of this.visiblePages) {
|
||||
if(oPage.lines.length > 0) return oPage.lines[0].id;
|
||||
}
|
||||
|
||||
return this.journal.openId;
|
||||
},
|
||||
currentDay() {
|
||||
const oEntry = this.entriesById.get(this.currentEntryId);
|
||||
return oEntry ? getDayKey(oEntry.time, oEntry.timezone) : '';
|
||||
},
|
||||
canTurnBack() {
|
||||
return (this.iSpread > 0) || this.journal.hasOlder;
|
||||
},
|
||||
canTurnForward() {
|
||||
return (this.iSpread < this.spreadCount - 1) || this.journal.hasNewer;
|
||||
},
|
||||
//The ribbon marks the spread being written on
|
||||
showRibbon() {
|
||||
const oPos = this.caretPosition;
|
||||
return (this.iPagesPerView > 1) && (oPos !== null) && (oPos.spread === this.iSpread);
|
||||
},
|
||||
measureStyle() {
|
||||
return {
|
||||
left: this.asTextBox.left + 'px',
|
||||
top: this.asTextBox.top + 'px',
|
||||
width: this.asTextBox.width + 'px'
|
||||
};
|
||||
},
|
||||
inputStyle() {
|
||||
return {
|
||||
left: this.asTextBox.left + 'px',
|
||||
top: this.asTextBox.top + 'px',
|
||||
width: this.asTextBox.width + 'px',
|
||||
height: this.asTextBox.height + 'px'
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
//A new entry was opened, or the book was reloaded around a date
|
||||
'journal.openId'() {
|
||||
this.$nextTick(() => this.refresh());
|
||||
},
|
||||
entryCount() {
|
||||
this.relayout();
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
iPagesPerView() {
|
||||
this.$nextTick(() => this.measure());
|
||||
},
|
||||
//The rail highlights, and the calendar opens on, whatever is being read
|
||||
currentEntryId: {
|
||||
immediate: true,
|
||||
handler(iEntryId) {
|
||||
this.$emit('reading', {id: iEntryId, day: this.currentDay});
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
//None of these are state the template renders, and the paginator holds
|
||||
//DOM nodes and a cache - proxying them would cost on every keystroke.
|
||||
this.oPaginator = new Paginator();
|
||||
this.oRange = document.createRange();
|
||||
this.oResizeObserver = null;
|
||||
this.oTurnTimer = null;
|
||||
this.oMedia = null;
|
||||
},
|
||||
async mounted() {
|
||||
this.oMedia = window.matchMedia('(max-width: ' + SINGLE_PAGE_WIDTH + 'px)');
|
||||
this.iPagesPerView = this.oMedia.matches ? 1 : 2;
|
||||
this.oMedia.addEventListener('change', this.onMediaChange);
|
||||
|
||||
//The paginator measures whatever font is actually loaded, so measuring
|
||||
//before the hand arrives would lay the whole book out in the fallback.
|
||||
if(document.fonts?.ready) await document.fonts.ready;
|
||||
|
||||
await this.measure();
|
||||
|
||||
this.oResizeObserver = new ResizeObserver(() => this.measure());
|
||||
this.oResizeObserver.observe(this.$refs.book);
|
||||
|
||||
document.addEventListener('selectionchange', this.onSelectionChange);
|
||||
|
||||
this.refresh();
|
||||
this.goToWriting();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.oResizeObserver?.disconnect();
|
||||
this.oMedia?.removeEventListener('change', this.onMediaChange);
|
||||
document.removeEventListener('selectionchange', this.onSelectionChange);
|
||||
clearTimeout(this.oTurnTimer);
|
||||
},
|
||||
methods: {
|
||||
onMediaChange(oEvent) {
|
||||
this.iPagesPerView = oEvent.matches ? 1 : 2;
|
||||
},
|
||||
|
||||
/* Measuring */
|
||||
|
||||
/**
|
||||
* Resolve the two numbers the whole layout hangs on - how tall a ruled
|
||||
* line is, and how many of them fit on a page - and hand them to the
|
||||
* paginator. Everything else follows from those.
|
||||
*/
|
||||
async measure() {
|
||||
const oBook = this.$refs.book;
|
||||
const oMeasure = this.$refs.measure;
|
||||
if(!oBook || !oMeasure) return;
|
||||
|
||||
//The measurer has to sit at the width of a real column before its
|
||||
//line height means anything.
|
||||
this.readTextBox();
|
||||
await this.$nextTick();
|
||||
|
||||
const iLineHeight = parseFloat(getComputedStyle(oMeasure).lineHeight) || 0;
|
||||
if(iLineHeight <= 0) return;
|
||||
|
||||
const oBody = this.getPageComponent(this.iSpread * this.iPagesPerView)?.getBodyElement();
|
||||
if(!oBody) return;
|
||||
|
||||
//Whole rules only: a page that ends on half a line looks like a bug.
|
||||
const iLines = Math.max(1, Math.floor(oBody.clientHeight / iLineHeight));
|
||||
|
||||
this.iLineHeight = iLineHeight;
|
||||
this.iLinesPerPage = iLines;
|
||||
oBook.style.setProperty('--book-lines', String(iLines));
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
if(this.oPaginator.setMetrics(oMeasure, iLineHeight, iLines)) this.relayout();
|
||||
|
||||
this.bReady = true;
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
|
||||
//Geometry of the column the input and the measurer overlay
|
||||
readTextBox() {
|
||||
const oSpread = this.$refs.spread;
|
||||
if(!oSpread) return;
|
||||
|
||||
const oPos = this.caretPosition;
|
||||
const iAnchor = oPos ? oPos.page : (this.iSpread * this.iPagesPerView);
|
||||
const oPage = this.getPageComponent(iAnchor) || this.getPageComponent(this.iSpread * this.iPagesPerView);
|
||||
const oLines = oPage?.getLinesElement();
|
||||
if(!oLines) return;
|
||||
|
||||
const oSpreadRect = oSpread.getBoundingClientRect();
|
||||
const oLinesRect = oLines.getBoundingClientRect();
|
||||
|
||||
this.asTextBox = {
|
||||
left: oLinesRect.left - oSpreadRect.left,
|
||||
top: oLinesRect.top - oSpreadRect.top,
|
||||
width: oLinesRect.width,
|
||||
height: Math.max(1, this.iLinesPerPage) * Math.max(1, this.iLineHeight)
|
||||
};
|
||||
},
|
||||
|
||||
relayout() {
|
||||
if(!this.oPaginator.ready) return;
|
||||
this.asLayout = this.oPaginator.layout(this.journal.entries);
|
||||
},
|
||||
|
||||
/* Drawing what the input cannot */
|
||||
|
||||
paintOverlay() {
|
||||
this.readTextBox();
|
||||
|
||||
const oPos = this.caretPosition;
|
||||
|
||||
if(oPos && this.bFocused && (oPos.spread === this.iSpread)) {
|
||||
const oPage = this.getPageComponent(oPos.page);
|
||||
const oLine = this.flatLines[oPos.global];
|
||||
|
||||
this.asCaret = (oPage && oLine) ?
|
||||
{page: oPos.page, line: oPos.line, x: this.getCaretX(oPage, oPos.line, oLine, oPos.offset - oLine.start)}
|
||||
:
|
||||
null;
|
||||
}
|
||||
else this.asCaret = null;
|
||||
|
||||
this.asSelection = this.buildSelection();
|
||||
},
|
||||
|
||||
getCaretX(oPage, iLine, oLine, iChars) {
|
||||
const oLineEl = oPage.getLineElement(iLine);
|
||||
const oLinesEl = oPage.getLinesElement();
|
||||
if(!oLineEl || !oLinesEl) return 0;
|
||||
|
||||
const iOrigin = oLinesEl.getBoundingClientRect().left;
|
||||
return this.getBoundaryX(oLineEl, iChars) - iOrigin;
|
||||
},
|
||||
|
||||
/** Client x of the boundary `iChars` characters into a rendered line */
|
||||
getBoundaryX(oLineEl, iChars) {
|
||||
const oText = oLineEl.firstChild;
|
||||
if(!oText || (iChars <= 0)) return oLineEl.getBoundingClientRect().left;
|
||||
|
||||
this.oRange.setStart(oText, 0);
|
||||
this.oRange.setEnd(oText, Math.min(iChars, oText.length));
|
||||
return this.oRange.getBoundingClientRect().right;
|
||||
},
|
||||
|
||||
/**
|
||||
* The input's own selection is invisible, so the book draws one rect per
|
||||
* visual line the selection covers.
|
||||
*/
|
||||
buildSelection() {
|
||||
const oOpen = this.openEntry;
|
||||
if(!oOpen || (this.iSelectionStart >= this.iSelectionEnd)) return [];
|
||||
|
||||
const asRects = [];
|
||||
const iFirstPage = this.iSpread * this.iPagesPerView;
|
||||
|
||||
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
|
||||
const iPage = iFirstPage + iOffset;
|
||||
const oPage = this.getPageComponent(iPage);
|
||||
const oLinesEl = oPage?.getLinesElement();
|
||||
if(!oLinesEl) continue;
|
||||
|
||||
const iOrigin = oLinesEl.getBoundingClientRect().left;
|
||||
|
||||
(this.pages[iPage] || []).forEach((oLine, iLine) => {
|
||||
if(oLine.id !== oOpen.id) return;
|
||||
|
||||
const iFrom = Math.max(this.iSelectionStart, oLine.start);
|
||||
const iTo = Math.min(this.iSelectionEnd, oLine.end);
|
||||
if(iTo < iFrom) return;
|
||||
|
||||
const oLineEl = oPage.getLineElement(iLine);
|
||||
if(!oLineEl) return;
|
||||
|
||||
const iLeft = this.getBoundaryX(oLineEl, iFrom - oLine.start) - iOrigin;
|
||||
const iRight = this.getBoundaryX(oLineEl, iTo - oLine.start) - iOrigin;
|
||||
|
||||
asRects.push({
|
||||
page: iPage,
|
||||
line: iLine,
|
||||
left: iLeft,
|
||||
//A selected line break has no width of its own, but it
|
||||
//was still selected - show it as a thin mark.
|
||||
width: Math.max(3, iRight - iLeft)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return asRects;
|
||||
},
|
||||
|
||||
caretFor(iPage) {
|
||||
return (this.asCaret && (this.asCaret.page === iPage)) ? this.asCaret : null;
|
||||
},
|
||||
selectionFor(iPage) {
|
||||
return this.asSelection.filter((oRect) => oRect.page === iPage);
|
||||
},
|
||||
placeholderFor(iPage) {
|
||||
const oOpen = this.openEntry;
|
||||
if(!oOpen || (oOpen.content !== '')) return -1;
|
||||
|
||||
const oPos = this.asLayout.index.get(oOpen.id);
|
||||
return (oPos && (oPos.firstPage === iPage)) ? oPos.firstLineOnPage : -1;
|
||||
},
|
||||
isWritable(iPage) {
|
||||
const oOpen = this.openEntry;
|
||||
if(!oOpen) return false;
|
||||
|
||||
return (this.pages[iPage] || []).some((oLine) => oLine.id === oOpen.id);
|
||||
},
|
||||
/**
|
||||
* The page component showing a given page of the book.
|
||||
*
|
||||
* Matched on the page it is actually displaying, not on its position in
|
||||
* the ref array: Vue does not promise that a v-for ref array is in
|
||||
* source order, and taking it on trust measures the caret against the
|
||||
* facing page's text - which puts it somewhere else entirely on the line.
|
||||
*/
|
||||
getPageComponent(iPageIndex) {
|
||||
const aoPages = this.$refs.pageEls;
|
||||
if(!Array.isArray(aoPages)) return null;
|
||||
|
||||
return aoPages.find((oPage) => oPage && (oPage.pageIndex === iPageIndex)) || null;
|
||||
},
|
||||
|
||||
/* Writing */
|
||||
|
||||
refresh() {
|
||||
const oInput = this.$refs.input;
|
||||
const oOpen = this.openEntry;
|
||||
|
||||
if(oInput && oOpen && (oInput.value !== oOpen.content)) {
|
||||
oInput.value = oOpen.content;
|
||||
oInput.setSelectionRange(oOpen.content.length, oOpen.content.length);
|
||||
}
|
||||
|
||||
this.relayout();
|
||||
this.$nextTick(() => this.syncCaret());
|
||||
},
|
||||
|
||||
onInput(oEvent) {
|
||||
this.journal.write(oEvent.target.value);
|
||||
this.relayout();
|
||||
this.syncCaret();
|
||||
},
|
||||
|
||||
onSelectionChange() {
|
||||
if(document.activeElement === this.$refs.input) this.syncCaret();
|
||||
},
|
||||
|
||||
/**
|
||||
* Mirror the input's caret onto the page - and, when the writing has run
|
||||
* off the bottom of the spread, turn to the page it ran onto.
|
||||
*/
|
||||
syncCaret() {
|
||||
const oInput = this.$refs.input;
|
||||
if(!oInput) return;
|
||||
|
||||
this.iSelectionStart = oInput.selectionStart;
|
||||
this.iSelectionEnd = oInput.selectionEnd;
|
||||
this.iCaretOffset = (oInput.selectionDirection === 'backward') ? oInput.selectionStart : oInput.selectionEnd;
|
||||
|
||||
const oPos = this.caretPosition;
|
||||
if(oPos && (oPos.spread !== this.iSpread)) this.setSpread(oPos.spread);
|
||||
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
|
||||
focusAt(iOffset, bExtend = false) {
|
||||
const oInput = this.$refs.input;
|
||||
if(!oInput) return;
|
||||
|
||||
oInput.focus({preventScroll: true});
|
||||
|
||||
if(bExtend) oInput.setSelectionRange(Math.min(oInput.selectionStart, iOffset), Math.max(oInput.selectionEnd, iOffset));
|
||||
else oInput.setSelectionRange(iOffset, iOffset);
|
||||
|
||||
this.syncCaret();
|
||||
},
|
||||
|
||||
/**
|
||||
* Back to the page being written on.
|
||||
*
|
||||
* Following a bookmark re-centres the loaded window somewhere else in
|
||||
* the book, which can leave the open entry outside it entirely - so the
|
||||
* writing page has to be fetched back before it can be turned to.
|
||||
*/
|
||||
async goToWriting() {
|
||||
let oOpen = this.openEntry;
|
||||
|
||||
if(!oOpen && (this.journal.openId > 0)) {
|
||||
await this.journal.loadAround(this.journal.openId);
|
||||
this.relayout();
|
||||
await this.$nextTick();
|
||||
oOpen = this.openEntry;
|
||||
}
|
||||
|
||||
if(!oOpen) return;
|
||||
|
||||
this.focusAt((oOpen.content || '').length);
|
||||
},
|
||||
|
||||
/** Clicking the paper puts the caret where you clicked */
|
||||
onPick({pageIndex, lineIndex, clientX, extend}) {
|
||||
const oOpen = this.openEntry;
|
||||
if(!oOpen) return;
|
||||
|
||||
const asLines = this.pages[pageIndex] || [];
|
||||
const oPage = this.getPageComponent(pageIndex);
|
||||
if(!oPage) return;
|
||||
|
||||
let iLine = lineIndex;
|
||||
let oLine = asLines[iLine];
|
||||
let bExact = true;
|
||||
|
||||
//Clicking below the writing, or on an entry already closed, lands at
|
||||
//the end of the nearest line that can actually be written on.
|
||||
if(!oLine || (oLine.id !== oOpen.id)) {
|
||||
bExact = false;
|
||||
oLine = null;
|
||||
|
||||
for(let iAbove = Math.min(lineIndex, asLines.length - 1); iAbove >= 0; iAbove--) {
|
||||
if(asLines[iAbove] && (asLines[iAbove].id === oOpen.id)) {
|
||||
iLine = iAbove;
|
||||
oLine = asLines[iAbove];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!oLine) return;
|
||||
|
||||
const iOffset = bExact ? this.getOffsetAtX(oPage, iLine, oLine, clientX) : oLine.end;
|
||||
this.focusAt(iOffset, extend);
|
||||
},
|
||||
|
||||
/** Nearest character boundary to a click, by binary search over the line */
|
||||
getOffsetAtX(oPage, iLine, oLine, iClientX) {
|
||||
const oLineEl = oPage.getLineElement(iLine);
|
||||
if(!oLineEl) return oLine.start;
|
||||
|
||||
const oText = oLineEl.firstChild;
|
||||
const iLength = Math.min(oLine.end - oLine.start, oText ? oText.length : 0);
|
||||
if(iLength <= 0) return oLine.start;
|
||||
|
||||
let iLow = 0;
|
||||
let iHigh = iLength;
|
||||
|
||||
while(iLow < iHigh) {
|
||||
const iMid = (iLow + iHigh) >> 1;
|
||||
if(this.getBoundaryX(oLineEl, iMid) < iClientX) iLow = iMid + 1;
|
||||
else iHigh = iMid;
|
||||
}
|
||||
|
||||
//Land on whichever side of the character the click actually fell
|
||||
if(iLow > 0) {
|
||||
const iBefore = this.getBoundaryX(oLineEl, iLow - 1);
|
||||
const iAfter = this.getBoundaryX(oLineEl, iLow);
|
||||
if(Math.abs(iClientX - iBefore) < Math.abs(iClientX - iAfter)) iLow--;
|
||||
}
|
||||
|
||||
return oLine.start + iLow;
|
||||
},
|
||||
|
||||
/* Turning */
|
||||
|
||||
/**
|
||||
* Turn to a spread, sending a leaf across the gutter to get there.
|
||||
*
|
||||
* A leaf has a page on each side, and the two it shows during the turn
|
||||
* are not the two you end up looking at: going forward, the leaf lifts
|
||||
* the old right-hand page and lands carrying the new left-hand one on
|
||||
* its back. Going back it is the mirror of that.
|
||||
*/
|
||||
setSpread(iSpread, sDirection = '') {
|
||||
const iNext = Math.max(0, Math.min(this.spreadCount - 1, iSpread));
|
||||
if(iNext === this.iSpread) return;
|
||||
|
||||
const iFrom = this.iSpread;
|
||||
const sDir = sDirection || ((iNext > iFrom) ? 'forward' : 'back');
|
||||
const bForward = (sDir === 'forward');
|
||||
|
||||
//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.iSpread = iNext;
|
||||
|
||||
clearTimeout(this.oTurnTimer);
|
||||
this.oTurnTimer = setTimeout(() => {
|
||||
this.asTurn = null;
|
||||
}, TURN_MS);
|
||||
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
|
||||
sideFor(iPage) {
|
||||
return ((iPage % 2) === 0) ? 'left' : 'right';
|
||||
},
|
||||
|
||||
async turn(iDelta) {
|
||||
//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)) {
|
||||
if(this.journal.hasOlder) await this.extendBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if((iDelta > 0) && (this.iSpread >= this.spreadCount - 1)) {
|
||||
if(!this.journal.hasNewer) return;
|
||||
|
||||
await this.journal.loadNewer();
|
||||
this.relayout();
|
||||
await this.$nextTick();
|
||||
this.setSpread(this.iSpread + 1, 'forward');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSpread(this.iSpread + iDelta);
|
||||
},
|
||||
|
||||
/**
|
||||
* Older entries are prepended, which shifts every page index - so the
|
||||
* page being read is pinned first and restored after.
|
||||
*/
|
||||
async extendBackwards() {
|
||||
const oAnchor = this.getAnchor();
|
||||
const iAdded = await this.journal.loadOlder();
|
||||
|
||||
this.relayout();
|
||||
await this.$nextTick();
|
||||
|
||||
if(oAnchor) this.restoreAnchor(oAnchor);
|
||||
if(iAdded > 0) this.setSpread(this.iSpread - 1, 'back');
|
||||
},
|
||||
|
||||
getAnchor() {
|
||||
const oLine = (this.pages[this.iSpread * this.iPagesPerView] || [])[0];
|
||||
return oLine ? {id: oLine.id, start: oLine.start} : null;
|
||||
},
|
||||
|
||||
restoreAnchor(oAnchor) {
|
||||
const iLine = this.flatLines.findIndex((oItem) => (oItem.id === oAnchor.id) && (oItem.start === oAnchor.start));
|
||||
if(iLine < 0) return;
|
||||
|
||||
const iPage = Math.floor(iLine / Math.max(1, this.asLayout.linesPerPage));
|
||||
this.iSpread = Math.floor(iPage / this.iPagesPerView);
|
||||
},
|
||||
|
||||
/** Turn to an entry, fetching it first if it is outside the window */
|
||||
async goToEntry(iEntryId) {
|
||||
if(!this.asLayout.index.has(iEntryId)) {
|
||||
await this.journal.loadAround(iEntryId);
|
||||
this.relayout();
|
||||
await this.$nextTick();
|
||||
}
|
||||
|
||||
const oPos = this.asLayout.index.get(iEntryId);
|
||||
if(!oPos) return;
|
||||
|
||||
this.setSpread(Math.floor(oPos.firstPage / this.iPagesPerView));
|
||||
},
|
||||
|
||||
onKeydown(oEvent) {
|
||||
//Only when the reader is not writing - otherwise these are just keys
|
||||
if(this.bFocused) return;
|
||||
|
||||
if(oEvent.key === 'PageUp' || oEvent.key === 'ArrowLeft') this.turn(-1);
|
||||
else if(oEvent.key === 'PageDown' || oEvent.key === 'ArrowRight') this.turn(1);
|
||||
else return;
|
||||
|
||||
oEvent.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="book" class="book" tabindex="-1" @keydown="onKeydown">
|
||||
<div class="book__shell">
|
||||
<div
|
||||
ref="spread"
|
||||
class="book__spread"
|
||||
:class="[
|
||||
(iPagesPerView === 1) ? 'book__spread--single' : null,
|
||||
asTurn ? 'book__turning' : null,
|
||||
asTurn ? ('book__turning--' + asTurn.dir) : null
|
||||
]"
|
||||
>
|
||||
<bookPage
|
||||
v-for="oPage in visiblePages"
|
||||
:key="oPage.index"
|
||||
ref="pageEls"
|
||||
:lines="oPage.lines"
|
||||
:entries="entriesById"
|
||||
:page-index="oPage.index"
|
||||
:side="(oPage.index % 2 === 0) ? 'left' : 'right'"
|
||||
:open-id="journal.openId"
|
||||
:line-height="iLineHeight"
|
||||
:lines-per-page="iLinesPerPage"
|
||||
:writable="isWritable(oPage.index)"
|
||||
:caret="caretFor(oPage.index)"
|
||||
:selection="selectionFor(oPage.index)"
|
||||
:placeholder-line="placeholderFor(oPage.index)"
|
||||
:placeholder="lang.get('book.write_here')"
|
||||
@pick="onPick"
|
||||
/>
|
||||
|
||||
<!-- 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. -->
|
||||
<div
|
||||
v-if="asTurn && (iPagesPerView > 1)"
|
||||
:key="asTurn.id"
|
||||
class="book__leaf"
|
||||
:class="'book__leaf--' + asTurn.dir"
|
||||
>
|
||||
<div class="book__leaf-face book__leaf-face--front">
|
||||
<bookPage
|
||||
:lines="pages[asTurn.front] || []"
|
||||
:entries="entriesById"
|
||||
:page-index="asTurn.front"
|
||||
:side="sideFor(asTurn.front)"
|
||||
:open-id="journal.openId"
|
||||
:line-height="iLineHeight"
|
||||
:lines-per-page="iLinesPerPage"
|
||||
/>
|
||||
<span class="book__leaf-shade"></span>
|
||||
</div>
|
||||
<div class="book__leaf-face book__leaf-face--back">
|
||||
<bookPage
|
||||
:lines="pages[asTurn.back] || []"
|
||||
:entries="entriesById"
|
||||
:page-index="asTurn.back"
|
||||
:side="sideFor(asTurn.back)"
|
||||
:open-id="journal.openId"
|
||||
:line-height="iLineHeight"
|
||||
:lines-per-page="iLinesPerPage"
|
||||
/>
|
||||
<span class="book__leaf-shade"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="iPagesPerView > 1" class="book__spine"></span>
|
||||
<span v-if="showRibbon" class="book__ribbon" style="height: 42%"></span>
|
||||
|
||||
<!-- Where the paginator does its measuring: one stable element,
|
||||
laid out at the exact width of a real text column. -->
|
||||
<div ref="measure" class="page__measure" :style="measureStyle" aria-hidden="true"></div>
|
||||
|
||||
<!-- The keystrokes, the selection and the arrow keys. Not the
|
||||
glyphs - the pages draw those. -->
|
||||
<textarea
|
||||
ref="input"
|
||||
class="page__input"
|
||||
:style="inputStyle"
|
||||
:readonly="!openEntry"
|
||||
spellcheck="false"
|
||||
autocapitalize="sentences"
|
||||
:aria-label="lang.get('book.write_here')"
|
||||
@input="onInput"
|
||||
@keyup="syncCaret"
|
||||
@focus="bFocused = true; paintOverlay()"
|
||||
@blur="bFocused = false; paintOverlay()"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="book__turner book__turner--back"
|
||||
:disabled="!canTurnBack"
|
||||
:title="lang.get('action.prev_page')"
|
||||
@click="turn(-1)"
|
||||
>
|
||||
<appIcon icon="chevronLeft" size="1.4em" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="book__turner book__turner--forward"
|
||||
:disabled="!canTurnForward"
|
||||
:title="lang.get('action.next_page')"
|
||||
@click="turn(1)"
|
||||
>
|
||||
<appIcon icon="chevronRight" size="1.4em" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script>
|
||||
import { formatDate, formatTime } from '@scripts/time';
|
||||
|
||||
/**
|
||||
* One page of the book.
|
||||
*
|
||||
* A page is dumb on purpose: it is handed a list of already-broken visual lines
|
||||
* and draws them, the stamps in its margin, and whatever overlay (caret,
|
||||
* selection) the book has measured for it. All the deciding - where lines
|
||||
* break, which page they land on, where the caret is - happens in Book.vue,
|
||||
* because none of it can be decided a page at a time.
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
//Visual lines for this page: {id, start, end, first}
|
||||
lines: {type: Array, required: true},
|
||||
//id -> entry, for the text and the margin stamps
|
||||
entries: {type: Map, required: true},
|
||||
//Position of this page in the whole book, for the page number
|
||||
pageIndex: {type: Number, required: true},
|
||||
side: {type: String, default: 'left'},
|
||||
//The entry still being written, drawn a shade darker than past pages
|
||||
openId: {type: Number, default: 0},
|
||||
lineHeight: {type: Number, default: 0},
|
||||
linesPerPage: {type: Number, default: 1},
|
||||
//Set on the page that currently holds the writing caret
|
||||
writable: {type: Boolean, default: false},
|
||||
//{line, x} in page coordinates, or null
|
||||
caret: {type: Object, default: null},
|
||||
//[{line, left, width}] - the book draws its own selection, since the
|
||||
//input's native one is invisible
|
||||
selection: {type: Array, default: () => []},
|
||||
//Line to hang the "write here" hint on, or -1
|
||||
placeholderLine: {type: Number, default: -1},
|
||||
placeholder: {type: String, default: ''}
|
||||
},
|
||||
emits: ['pick'],
|
||||
inject: ['lang'],
|
||||
computed: {
|
||||
//Where an entry begins, the margin says when it was written
|
||||
stamps() {
|
||||
const asStamps = [];
|
||||
|
||||
this.lines.forEach((oLine, iIndex) => {
|
||||
if(!oLine.first) return;
|
||||
|
||||
const oEntry = this.entries.get(oLine.id);
|
||||
if(!oEntry) return;
|
||||
|
||||
asStamps.push({
|
||||
id: oLine.id,
|
||||
line: iIndex,
|
||||
date: formatDate(oEntry.time, oEntry.timezone, this.lang.locale),
|
||||
time: formatTime(oEntry.time, oEntry.timezone, this.lang.locale)
|
||||
});
|
||||
});
|
||||
|
||||
return asStamps;
|
||||
},
|
||||
pageNumber() {
|
||||
return this.pageIndex + 1;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
lineText(oLine) {
|
||||
const oEntry = this.entries.get(oLine.id);
|
||||
return oEntry ? oEntry.content.slice(oLine.start, oLine.end) : '';
|
||||
},
|
||||
isOpenLine(oLine) {
|
||||
return (oLine.id === this.openId);
|
||||
},
|
||||
offsetFor(iLine) {
|
||||
return (iLine * this.lineHeight) + 'px';
|
||||
},
|
||||
|
||||
/* Read back by Book.vue, which needs real geometry to place the caret,
|
||||
* the selection, the input and the measurer. */
|
||||
|
||||
getLinesElement() {
|
||||
return this.$refs.lines || null;
|
||||
},
|
||||
getBodyElement() {
|
||||
return this.$refs.body || null;
|
||||
},
|
||||
/**
|
||||
* The nth rendered line.
|
||||
*
|
||||
* Queried from the DOM rather than read out of a `ref` on the v-for:
|
||||
* Vue makes no promise that a v-for ref array is in source order, and
|
||||
* when it is not, the caret gets measured against the wrong line - which
|
||||
* lands it back at the start of the line instead of after what was
|
||||
* just typed.
|
||||
*/
|
||||
getLineElement(iIndex) {
|
||||
const oLines = this.$refs.lines;
|
||||
if(!oLines) return null;
|
||||
|
||||
return oLines.querySelectorAll('.page__line')[iIndex] || null;
|
||||
},
|
||||
|
||||
onClick(oEvent) {
|
||||
if(!this.writable || (this.lineHeight <= 0)) return;
|
||||
|
||||
const oLines = this.$refs.lines;
|
||||
if(!oLines) return;
|
||||
|
||||
const oRect = oLines.getBoundingClientRect();
|
||||
const iLine = Math.floor((oEvent.clientY - oRect.top) / this.lineHeight);
|
||||
|
||||
this.$emit('pick', {
|
||||
pageIndex: this.pageIndex,
|
||||
lineIndex: Math.max(0, Math.min(this.linesPerPage - 1, iLine)),
|
||||
clientX: oEvent.clientX,
|
||||
extend: oEvent.shiftKey
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="page"
|
||||
:class="['page--' + side, writable ? 'page--writable' : null]"
|
||||
@mousedown.prevent="onClick"
|
||||
>
|
||||
<div ref="body" class="page__body">
|
||||
<div class="page__margin">
|
||||
<span
|
||||
v-for="oStamp in stamps"
|
||||
:key="oStamp.id"
|
||||
class="page__stamp"
|
||||
:style="{top: offsetFor(oStamp.line)}"
|
||||
>
|
||||
<span class="page__stamp-date">{{ oStamp.date }}</span>
|
||||
<span class="page__stamp-time">{{ oStamp.time }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="page__column">
|
||||
<div ref="lines" class="page__lines">
|
||||
<span
|
||||
v-for="(oSelection, iIndex) in selection"
|
||||
:key="'sel' + iIndex"
|
||||
class="page__selection"
|
||||
:style="{top: offsetFor(oSelection.line), left: oSelection.left + 'px', width: oSelection.width + 'px'}"
|
||||
></span>
|
||||
|
||||
<span
|
||||
v-for="(oLine, iIndex) in lines"
|
||||
:key="iIndex"
|
||||
class="page__line"
|
||||
:class="isOpenLine(oLine) ? null : 'page__line--closed'"
|
||||
>{{ lineText(oLine) }}</span>
|
||||
|
||||
<span
|
||||
v-if="placeholderLine >= 0"
|
||||
class="page__placeholder"
|
||||
:style="{top: offsetFor(placeholderLine)}"
|
||||
>{{ placeholder }}</span>
|
||||
|
||||
<span
|
||||
v-if="caret"
|
||||
class="page__caret"
|
||||
:style="{top: offsetFor(caret.line), left: caret.x + 'px'}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page__foot">
|
||||
<span class="page__number">{{ pageNumber }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { formatShortDate, formatTime } from '@scripts/time';
|
||||
|
||||
//Bookmarks shown either side of the entry being read. A whole book's worth of
|
||||
//tabs is a scrollbar, not a rail - a handful around where you are is something
|
||||
//you can actually aim at, and turning pages walks the window along.
|
||||
const RAIL_REACH = 3;
|
||||
|
||||
/**
|
||||
* The tabs down the side of the book: one per entry, in writing order.
|
||||
*
|
||||
* Each is stamped with the date and time the entry was started, in the timezone
|
||||
* it was written in - so a book kept across a move still reads as the local
|
||||
* time of each moment. Clicking one turns the book to that page.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon
|
||||
},
|
||||
props: {
|
||||
bookmarks: {type: Array, required: true},
|
||||
//The entry the visible spread belongs to
|
||||
currentId: {type: Number, default: 0},
|
||||
//The entry still being written
|
||||
openId: {type: Number, default: 0},
|
||||
hasOlder: {type: Boolean, default: false},
|
||||
loading: {type: Boolean, default: false}
|
||||
},
|
||||
emits: ['pick', 'load-older'],
|
||||
inject: ['lang'],
|
||||
computed: {
|
||||
tabs() {
|
||||
return this.bookmarks.map((oBookmark) => ({
|
||||
id: oBookmark.id,
|
||||
date: formatShortDate(oBookmark.time, oBookmark.timezone, this.lang.locale),
|
||||
time: formatTime(oBookmark.time, oBookmark.timezone, this.lang.locale),
|
||||
preview: oBookmark.preview || this.lang.get('book.empty_entry'),
|
||||
open: (oBookmark.status === 'open')
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* The three either side of where the reading is.
|
||||
*
|
||||
* Near the ends of the book there are simply fewer - the window is not
|
||||
* padded back to a fixed size, because a tab that is not next to where
|
||||
* you are is not what "next" means.
|
||||
*/
|
||||
shown() {
|
||||
const iCurrent = this.tabs.findIndex((oTab) => oTab.id === this.currentId);
|
||||
const iAnchor = (iCurrent >= 0) ? iCurrent : (this.tabs.length - 1);
|
||||
|
||||
return this.tabs.slice(
|
||||
Math.max(0, iAnchor - RAIL_REACH),
|
||||
Math.min(this.tabs.length, iAnchor + RAIL_REACH + 1)
|
||||
);
|
||||
},
|
||||
//Whether the window itself has more book beyond it, in either direction
|
||||
hasBefore() {
|
||||
return (this.shown.length > 0) && (this.shown[0].id !== this.tabs[0]?.id);
|
||||
},
|
||||
hasAfter() {
|
||||
return (this.shown.length > 0) && (this.shown.at(-1).id !== this.tabs.at(-1)?.id);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentId() {
|
||||
this.$nextTick(() => this.scrollToCurrent());
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//The rail is chronological, so the page being written is at the bottom.
|
||||
this.$nextTick(() => this.scrollToCurrent());
|
||||
},
|
||||
methods: {
|
||||
scrollToCurrent() {
|
||||
const oList = this.$refs.list;
|
||||
if(!oList) return;
|
||||
|
||||
const oTab = oList.querySelector('.rail__tab--current');
|
||||
if(oTab) oTab.scrollIntoView({block: 'nearest'});
|
||||
else oList.scrollTop = oList.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 -->
|
||||
<button
|
||||
v-if="hasBefore || hasOlder"
|
||||
type="button"
|
||||
class="rail__more"
|
||||
:disabled="loading"
|
||||
:title="lang.get('action.prev_page')"
|
||||
@click="$emit('load-older')"
|
||||
>
|
||||
<appIcon icon="chevronLeft" size="0.8em" />
|
||||
</button>
|
||||
|
||||
<div ref="list" class="rail__list">
|
||||
<button
|
||||
v-for="oTab in shown"
|
||||
:key="oTab.id"
|
||||
type="button"
|
||||
class="rail__tab"
|
||||
:class="{
|
||||
'rail__tab--current': (oTab.id === currentId),
|
||||
'rail__tab--open': (oTab.id === openId)
|
||||
}"
|
||||
@click="$emit('pick', oTab.id)"
|
||||
>
|
||||
<span class="rail__date">
|
||||
<span class="rail__day">{{ oTab.date }}</span>
|
||||
<span class="rail__time">{{ oTab.time }}</span>
|
||||
</span>
|
||||
<span class="rail__preview">{{ oTab.preview }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="tabs.length === 0" class="rail__empty">{{ lang.get('book.first_page') }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { formatMonthTitle, getLocalDayKey, getWeekdayInitials } from '@scripts/time';
|
||||
|
||||
/**
|
||||
* A month at a time, with a mark under every day that was written on.
|
||||
*
|
||||
* The marks come from the bookmark list, which the journal already holds in
|
||||
* full - so this needs no request of its own, and doubles as a picture of how
|
||||
* regularly the book is being kept.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon
|
||||
},
|
||||
props: {
|
||||
//Map of 'YYYY-MM-DD' to number of entries started that day
|
||||
days: {type: Map, required: true},
|
||||
//The day the book is currently open at
|
||||
currentDay: {type: String, default: ''}
|
||||
},
|
||||
emits: ['pick'],
|
||||
inject: ['lang'],
|
||||
data() {
|
||||
return {
|
||||
//Opens on the month being read, or on this month
|
||||
oMonth: this.getMonthStart(this.currentDay)
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
monthTitle() {
|
||||
return formatMonthTitle(this.oMonth, this.lang.locale);
|
||||
},
|
||||
weekdays() {
|
||||
return getWeekdayInitials(this.lang.locale);
|
||||
},
|
||||
today() {
|
||||
return getLocalDayKey(new Date());
|
||||
},
|
||||
//Six full weeks, always - a grid that changes height as you page
|
||||
//through months is far more distracting than one blank row.
|
||||
cells() {
|
||||
const oFirst = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth(), 1);
|
||||
|
||||
//getDay() is Sunday-first; the grid is Monday-first
|
||||
const iLead = (oFirst.getDay() + 6) % 7;
|
||||
const asCells = [];
|
||||
|
||||
for(let iCell = 0; iCell < 42; iCell++) {
|
||||
const oDate = new Date(oFirst.getFullYear(), oFirst.getMonth(), 1 - iLead + iCell);
|
||||
const sKey = getLocalDayKey(oDate);
|
||||
|
||||
asCells.push({
|
||||
key: sKey,
|
||||
day: oDate.getDate(),
|
||||
outside: (oDate.getMonth() !== this.oMonth.getMonth()),
|
||||
future: (sKey > this.today),
|
||||
count: this.days.get(sKey) || 0
|
||||
});
|
||||
}
|
||||
|
||||
return asCells;
|
||||
},
|
||||
monthCount() {
|
||||
return this.cells.reduce((iTotal, oCell) => iTotal + (oCell.outside ? 0 : oCell.count), 0);
|
||||
},
|
||||
//Nothing was ever written in the future, so there is nowhere to go
|
||||
atLastMonth() {
|
||||
const oNow = new Date();
|
||||
return (this.oMonth.getFullYear() === oNow.getFullYear()) && (this.oMonth.getMonth() === oNow.getMonth());
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentDay(sDay) {
|
||||
if(sDay !== '') this.oMonth = this.getMonthStart(sDay);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getMonthStart(sDay) {
|
||||
//'YYYY-MM-DD' split by hand: new Date('2026-03-01') is parsed as UTC
|
||||
//and can land in the previous month west of Greenwich.
|
||||
const asParts = (sDay || '').split('-');
|
||||
if(asParts.length === 3) return new Date(Number(asParts[0]), Number(asParts[1]) - 1, 1);
|
||||
|
||||
const oNow = new Date();
|
||||
return new Date(oNow.getFullYear(), oNow.getMonth(), 1);
|
||||
},
|
||||
step(iMonths) {
|
||||
this.oMonth = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth() + iMonths, 1);
|
||||
},
|
||||
goToToday() {
|
||||
const oNow = new Date();
|
||||
this.oMonth = new Date(oNow.getFullYear(), oNow.getMonth(), 1);
|
||||
this.$emit('pick', this.today);
|
||||
},
|
||||
pick(oCell) {
|
||||
this.$emit('pick', oCell.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<div class="calendar__head">
|
||||
<button
|
||||
type="button"
|
||||
class="calendar__step"
|
||||
:title="lang.get('action.prev_page')"
|
||||
@click="step(-1)"
|
||||
>
|
||||
<appIcon icon="chevronLeft" />
|
||||
</button>
|
||||
|
||||
<span class="calendar__month">{{ monthTitle }}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="calendar__step"
|
||||
:disabled="atLastMonth"
|
||||
:title="lang.get('action.next_page')"
|
||||
@click="step(1)"
|
||||
>
|
||||
<appIcon icon="chevronRight" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar__grid">
|
||||
<span v-for="sDay in weekdays" :key="sDay" class="calendar__weekday">{{ sDay }}</span>
|
||||
|
||||
<button
|
||||
v-for="oCell in cells"
|
||||
:key="oCell.key"
|
||||
type="button"
|
||||
class="calendar__day"
|
||||
:class="{
|
||||
'calendar__day--outside': oCell.outside,
|
||||
'calendar__day--written': (oCell.count > 0),
|
||||
'calendar__day--today': (oCell.key === today),
|
||||
'calendar__day--current': (oCell.key === currentDay)
|
||||
}"
|
||||
:disabled="oCell.future"
|
||||
@click="pick(oCell)"
|
||||
>
|
||||
{{ oCell.day }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar__foot">
|
||||
<button type="button" class="calendar__link" @click="goToToday">
|
||||
{{ lang.get('book.today') }}
|
||||
</button>
|
||||
<span class="calendar__count">{{ monthCount }} {{ lang.get('book.entries').toLowerCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { SAVE_FAILED, SAVE_IDLE, SAVE_PENDING, SAVE_SAVED, SAVE_SAVING } from '@scripts/journal';
|
||||
import { formatSince } from '@scripts/time';
|
||||
|
||||
/**
|
||||
* Ambient reassurance that the writing is landing somewhere.
|
||||
*
|
||||
* Deliberately quiet: autosave is the normal case, so the only state allowed to
|
||||
* draw the eye is the one that needs a decision - a save that failed, which
|
||||
* becomes a button that retries it.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon
|
||||
},
|
||||
inject: ['journal', 'lang'],
|
||||
data() {
|
||||
return {
|
||||
//Bumped on an interval purely so the "saved 3 minutes ago" stamp
|
||||
//keeps up without the journal having to emit anything.
|
||||
iTick: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
state() {
|
||||
return this.journal.saveState;
|
||||
},
|
||||
visible() {
|
||||
return (this.state !== SAVE_IDLE) || (this.journal.savedAt > 0);
|
||||
},
|
||||
failed() {
|
||||
return (this.state === SAVE_FAILED);
|
||||
},
|
||||
label() {
|
||||
switch(this.state) {
|
||||
case SAVE_SAVING:
|
||||
return this.lang.get('save.saving');
|
||||
case SAVE_PENDING:
|
||||
return this.lang.get('save.pending');
|
||||
case SAVE_FAILED:
|
||||
return this.lang.get('save.failed');
|
||||
default:
|
||||
return (this.journal.savedAt > 0) ? this.lang.get('save.saved', this.since) : '';
|
||||
}
|
||||
},
|
||||
since() {
|
||||
//Reading iTick is what subscribes this to the interval.
|
||||
return (this.iTick >= 0) ? formatSince(this.journal.savedAt, this.lang.locale, this.lang.get('save.just_now')) : '';
|
||||
},
|
||||
icon() {
|
||||
if(this.failed) return 'alert';
|
||||
return (this.state === SAVE_SAVED) ? 'check' : '';
|
||||
},
|
||||
classes() {
|
||||
return {
|
||||
'saver--saving': (this.state === SAVE_SAVING),
|
||||
'saver--pending': (this.state === SAVE_PENDING),
|
||||
'saver--failed': this.failed
|
||||
};
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.oTicker = setInterval(() => this.iTick++, 30000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.oTicker);
|
||||
},
|
||||
methods: {
|
||||
retry() {
|
||||
if(this.failed) this.journal.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="failed ? 'button' : 'span'"
|
||||
v-if="visible"
|
||||
class="saver"
|
||||
:class="classes"
|
||||
:type="failed ? 'button' : null"
|
||||
:title="failed ? journal.error : null"
|
||||
@click="retry"
|
||||
>
|
||||
<appIcon v-if="icon" :icon="icon" size="0.95em" />
|
||||
<span v-else class="saver__dot"></span>
|
||||
<span>{{ label }}</span>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
|
||||
/**
|
||||
* Account settings, saved one field at a time.
|
||||
*
|
||||
* There is no Save button on purpose: each field commits on change, the same
|
||||
* way the book itself autosaves. A language change is the one setting that
|
||||
* needs the page back to pick up the new dictionary.
|
||||
*/
|
||||
export default {
|
||||
components: {
|
||||
appIcon
|
||||
},
|
||||
emits: ['close', 'signed-out'],
|
||||
inject: ['api', 'consts', 'lang', 'user'],
|
||||
data() {
|
||||
return {
|
||||
sName: this.user.name,
|
||||
sError: '',
|
||||
sSaved: '',
|
||||
bBusy: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
languages() {
|
||||
return this.consts.languages || ['en'];
|
||||
},
|
||||
//Intl knows the zone list; older engines only know the one in use.
|
||||
timezones() {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone');
|
||||
}
|
||||
catch{
|
||||
return [this.user.timezone].filter((sZone) => sZone !== '');
|
||||
}
|
||||
},
|
||||
languageNames() {
|
||||
const oNames = new Intl.DisplayNames([this.lang.locale], {type: 'language'});
|
||||
return this.languages.map((sCode) => ({
|
||||
code: sCode,
|
||||
label: oNames.of(sCode) || sCode
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async set(sField, sValue) {
|
||||
this.bBusy = true;
|
||||
this.sError = '';
|
||||
this.sSaved = '';
|
||||
|
||||
try {
|
||||
const asData = await this.api.post('account', {field: sField, value: sValue});
|
||||
Object.assign(this.user, asData.user);
|
||||
this.sSaved = sField;
|
||||
|
||||
//The dictionary is baked into the page, so a new language means
|
||||
//a new page - and nothing is lost, the book is already saved.
|
||||
if(sField === 'language') window.location.reload();
|
||||
}
|
||||
catch(oError) {
|
||||
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
|
||||
this.sName = this.user.name;
|
||||
}
|
||||
finally {
|
||||
this.bBusy = false;
|
||||
}
|
||||
},
|
||||
saveName() {
|
||||
const sName = this.sName.trim();
|
||||
if((sName !== '') && (sName !== this.user.name)) this.set('name', sName);
|
||||
},
|
||||
async signOut() {
|
||||
this.bBusy = true;
|
||||
try {
|
||||
await this.api.post('logout');
|
||||
this.$emit('signed-out');
|
||||
}
|
||||
catch(oError) {
|
||||
this.sError = oError.desc_lang_text || oError.message;
|
||||
}
|
||||
finally {
|
||||
this.bBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="veil" @click.self="$emit('close')">
|
||||
<div class="leaf leaf--wide" role="dialog" aria-modal="true">
|
||||
<div class="leaf__head">
|
||||
<h2 class="leaf__title">{{ lang.get('account.settings') }}</h2>
|
||||
<p class="leaf__sub">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="set-name">{{ lang.get('account.name') }}</label>
|
||||
<input
|
||||
id="set-name"
|
||||
v-model="sName"
|
||||
class="paper-field"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:disabled="bBusy"
|
||||
@change="saveName"
|
||||
@keyup.enter="saveName"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="set-lang">{{ lang.get('account.language') }}</label>
|
||||
<select
|
||||
id="set-lang"
|
||||
class="paper-field"
|
||||
:value="user.language"
|
||||
:disabled="bBusy"
|
||||
@change="set('language', $event.target.value)"
|
||||
>
|
||||
<option v-for="oLanguage in languageNames" :key="oLanguage.code" :value="oLanguage.code">
|
||||
{{ oLanguage.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="leaf__row">
|
||||
<label class="paper-label" for="set-tz">{{ lang.get('account.timezone') }}</label>
|
||||
<select
|
||||
id="set-tz"
|
||||
class="paper-field"
|
||||
:value="user.timezone"
|
||||
:disabled="bBusy"
|
||||
@change="set('timezone', $event.target.value)"
|
||||
>
|
||||
<option v-for="sZone in timezones" :key="sZone" :value="sZone">{{ sZone }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="leaf__actions">
|
||||
<button type="button" class="ink-button" @click="$emit('close')">
|
||||
<appIcon icon="check" />
|
||||
<span>{{ lang.get('action.close') }}</span>
|
||||
</button>
|
||||
<button type="button" class="text-button text-button--bad" :disabled="bBusy" @click="signOut">
|
||||
<span>{{ lang.get('account.sign_out') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="MyThoughts">
|
||||
<title>MyThoughts</title>
|
||||
<defs>
|
||||
<linearGradient id="desk" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0" stop-color="#44321e"/>
|
||||
<stop offset="0.55" stop-color="#2c2114"/>
|
||||
<stop offset="1" stop-color="#1a140d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Touch icons are masked by the OS, so the artwork keeps clear of the edges -->
|
||||
<rect width="180" height="180" fill="url(#desk)"/>
|
||||
<g transform="translate(90 92) scale(2.55) translate(-32 -33)">
|
||||
<path d="M32 19.5C27.7 15.9 22.2 14.2 14.5 14.2v32.4c7.7 0 13.2 1.7 17.5 5.2 4.3-3.5 9.8-5.2 17.5-5.2V14.2c-7.7 0-13.2 1.7-17.5 5.3z"
|
||||
fill="#f8f2e4" stroke="#cdbb99" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M32 19.5v32.3" stroke="#cdbb99" stroke-width="2" stroke-linecap="round"/>
|
||||
<g stroke="#6d6358" stroke-width="1.7" stroke-linecap="round" opacity="0.75">
|
||||
<path d="M20.5 26.5h7.5"/>
|
||||
<path d="M20.5 32h7.5"/>
|
||||
<path d="M20.5 37.5h5"/>
|
||||
</g>
|
||||
<path d="M36.5 14.6h6v16l-3-2.4-3 2.4z" fill="#795731"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MyThoughts">
|
||||
<title>MyThoughts</title>
|
||||
<rect width="64" height="64" rx="12" fill="#2c2114"/>
|
||||
<!-- The two pages, splayed from the gutter -->
|
||||
<path d="M32 19.5C27.7 15.9 22.2 14.2 14.5 14.2v32.4c7.7 0 13.2 1.7 17.5 5.2 4.3-3.5 9.8-5.2 17.5-5.2V14.2c-7.7 0-13.2 1.7-17.5 5.3z"
|
||||
fill="#f8f2e4" stroke="#cdbb99" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<!-- The gutter -->
|
||||
<path d="M32 19.5v32.3" stroke="#cdbb99" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Writing on the left page, trailing off the way a page in progress does -->
|
||||
<g stroke="#6d6358" stroke-width="1.7" stroke-linecap="round" opacity="0.75">
|
||||
<path d="M20.5 26.5h7.5"/>
|
||||
<path d="M20.5 32h7.5"/>
|
||||
<path d="M20.5 37.5h5"/>
|
||||
</g>
|
||||
<!-- The ribbon, marking today's page -->
|
||||
<path d="M36.5 14.6h6v16l-3-2.4-3 2.4z" fill="#795731"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 903 B |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "MyThoughts",
|
||||
"short_name": "MyThoughts",
|
||||
"description": "A quiet place to write. Your thoughts, on paper.",
|
||||
"start_url": "../../../",
|
||||
"scope": "../../../",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#2c2114",
|
||||
"theme_color": "#2c2114",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,86 @@
|
||||
export default class Api {
|
||||
|
||||
constructor({server, processPage, timezone, csrfToken, errorCode, lang}) {
|
||||
this.server = server;
|
||||
this.processPage = processPage;
|
||||
this.timezone = timezone;
|
||||
this.csrfToken = csrfToken;
|
||||
this.errorCode = errorCode;
|
||||
this.lang = lang;
|
||||
}
|
||||
|
||||
async get(sAction, asParams = {}) {
|
||||
const oResponse = await this.request(sAction, asParams, 'GET');
|
||||
return oResponse.data;
|
||||
}
|
||||
|
||||
async post(sAction, asParams = {}) {
|
||||
const oResponse = await this.request(sAction, asParams, 'POST');
|
||||
return oResponse.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget POST that survives the page going away. Used for the
|
||||
* close-the-entry call on unload, where a fetch would simply be cancelled.
|
||||
* sendBeacon cannot set headers, so the CSRF token rides in the body -
|
||||
* which the controller accepts as a fallback for exactly this case.
|
||||
*/
|
||||
beacon(sAction, asParams = {}) {
|
||||
if(!navigator.sendBeacon) return false;
|
||||
|
||||
const oBody = new URLSearchParams({
|
||||
...asParams,
|
||||
a: sAction,
|
||||
t: this.timezone,
|
||||
csrf_token: this.csrfToken
|
||||
});
|
||||
|
||||
return navigator.sendBeacon(
|
||||
new URL(this.processPage, this.server),
|
||||
new Blob([oBody.toString()], {type: 'application/x-www-form-urlencoded;charset=UTF-8'})
|
||||
);
|
||||
}
|
||||
|
||||
async request(sAction, asParams = {}, sMethod = 'GET') {
|
||||
const oUrl = new URL(this.processPage, this.server);
|
||||
|
||||
const sUrlParams = new URLSearchParams({
|
||||
...asParams,
|
||||
a: sAction,
|
||||
t: this.timezone
|
||||
}).toString();
|
||||
|
||||
const asOptions = {
|
||||
method: sMethod,
|
||||
headers: {'Accept': 'application/json'}
|
||||
};
|
||||
|
||||
if(sMethod === 'GET') {
|
||||
oUrl.search = sUrlParams;
|
||||
}
|
||||
else {
|
||||
asOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
asOptions.headers['X-CSRF-Token'] = this.csrfToken;
|
||||
asOptions.body = sUrlParams;
|
||||
}
|
||||
|
||||
const oRequest = await fetch(oUrl, asOptions);
|
||||
|
||||
if(!oRequest.ok) {
|
||||
throw new Error('Error HTTP ' + oRequest.status + ': ' + oRequest.statusText);
|
||||
}
|
||||
|
||||
const oResponse = await oRequest.json();
|
||||
oResponse.desc_lang_text = this.lang.get(oResponse.desc_lang_id, oResponse.desc_lang_params);
|
||||
|
||||
if(oResponse.result == this.errorCode) {
|
||||
const oError = new Error(oResponse.desc_lang_text || oResponse.desc_lang_id);
|
||||
oError.desc_lang_id = oResponse.desc_lang_id;
|
||||
oError.desc_lang_params = oResponse.desc_lang_params;
|
||||
oError.desc_lang_text = oResponse.desc_lang_text;
|
||||
throw oError;
|
||||
}
|
||||
|
||||
return oResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The icon set, as raw path data on a 24x24 grid.
|
||||
*
|
||||
* Kept as data rather than as files so the icons ship inside the JS bundle and
|
||||
* inherit `currentColor` - which matters here because the same glyph appears
|
||||
* both on paper (dark ink) and on the desk (warm white), and an <img> could do
|
||||
* neither. Everything is stroked, nothing is filled, so they sit next to a
|
||||
* handwriting font without looking like UI chrome.
|
||||
*/
|
||||
const asIcons = {
|
||||
//An open book, seen from above - the brand mark
|
||||
book: [
|
||||
'M12 6.6C10.1 5.1 7.7 4.4 4.4 4.4v12.9c3.3 0 5.7.7 7.6 2.3 1.9-1.6 4.3-2.3 7.6-2.3V4.4c-3.3 0-5.7.7-7.6 2.2z',
|
||||
'M12 6.6v12.9'
|
||||
],
|
||||
calendar: [
|
||||
'M4.6 6.9h14.8v12.5H4.6z',
|
||||
'M4.6 10.6h14.8',
|
||||
'M8.5 4.6v3.6',
|
||||
'M15.5 4.6v3.6'
|
||||
],
|
||||
//Calendar with the day marked: "jump to today"
|
||||
today: [
|
||||
'M4.6 6.9h14.8v12.5H4.6z',
|
||||
'M4.6 10.6h14.8',
|
||||
'M8.5 4.6v3.6',
|
||||
'M15.5 4.6v3.6',
|
||||
'M11.9 14.6h.2'
|
||||
],
|
||||
chevronLeft: ['M14.6 5.4 8 12l6.6 6.6'],
|
||||
chevronRight: ['M9.4 5.4 16 12l-6.6 6.6'],
|
||||
bookmark: ['M6.8 4.6h10.4v15.3L12 16.2l-5.2 3.7z'],
|
||||
pen: [
|
||||
'M4.8 19.2l1-3.7L15.4 6l2.7 2.7-9.6 9.5z',
|
||||
'M13.6 7.8l2.7 2.7'
|
||||
],
|
||||
user: [
|
||||
'M12 11.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2z',
|
||||
'M4.8 20c.6-3.7 3.5-5.8 7.2-5.8s6.6 2.1 7.2 5.8'
|
||||
],
|
||||
settings: [
|
||||
'M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z',
|
||||
'M12 3v2.3',
|
||||
'M12 18.7V21',
|
||||
'M3 12h2.3',
|
||||
'M18.7 12H21',
|
||||
'M5.6 5.6l1.7 1.7',
|
||||
'M16.7 16.7l1.7 1.7',
|
||||
'M18.4 5.6l-1.7 1.7',
|
||||
'M7.3 16.7l-1.7 1.7'
|
||||
],
|
||||
signOut: [
|
||||
'M14.4 8V5.2H5.6v13.6h8.8V16',
|
||||
'M10.2 12h9.4',
|
||||
'M16.9 9.2 19.6 12l-2.7 2.8'
|
||||
],
|
||||
trash: [
|
||||
'M5.6 7.5h12.8',
|
||||
'M9.5 7.5V5.2h5v2.3',
|
||||
'M7 7.5l.9 12.3h8.2L17 7.5',
|
||||
'M10.5 10.9v5.7',
|
||||
'M13.5 10.9v5.7'
|
||||
],
|
||||
close: ['M6.6 6.6l10.8 10.8', 'M17.4 6.6 6.6 17.4'],
|
||||
check: ['M5.6 12.7l4.2 4.2 8.6-9.6'],
|
||||
alert: [
|
||||
'M12 4.7 3.3 19.3h17.4z',
|
||||
'M12 9.7v4.4',
|
||||
'M11.9 16.7h.2'
|
||||
]
|
||||
};
|
||||
|
||||
export function getIconPaths(sName) {
|
||||
return asIcons[sName] || [];
|
||||
}
|
||||
|
||||
export function hasIcon(sName) {
|
||||
return Object.prototype.hasOwnProperty.call(asIcons, sName);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { getDayKey } from '@scripts/time';
|
||||
|
||||
export const SAVE_IDLE = 'idle';
|
||||
export const SAVE_PENDING = 'pending';
|
||||
export const SAVE_SAVING = 'saving';
|
||||
export const SAVE_SAVED = 'saved';
|
||||
export const SAVE_FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* The loaded stretch of the journal, plus the entry currently being written.
|
||||
*
|
||||
* The book is one continuous stream, but a long journal is not something to
|
||||
* ship in a single response - so this keeps a moving window of entries and
|
||||
* extends it when the reader turns past either edge. Bookmarks are the one
|
||||
* thing held in full: they are id-and-timestamp only, and both the rail and
|
||||
* the calendar are built from them.
|
||||
*/
|
||||
export default class Journal {
|
||||
|
||||
constructor(oApi, asConsts) {
|
||||
this.api = oApi;
|
||||
this.consts = asConsts;
|
||||
|
||||
this.entries = [];
|
||||
this.bookmarks = [];
|
||||
this.hasOlder = false;
|
||||
this.hasNewer = false;
|
||||
|
||||
this.openId = 0;
|
||||
this.loading = false;
|
||||
this.error = '';
|
||||
|
||||
this.saveState = SAVE_IDLE;
|
||||
this.savedAt = 0;
|
||||
this.savedContent = '';
|
||||
|
||||
this.oSaveTimer = null;
|
||||
this.oMaxWaitTimer = null;
|
||||
}
|
||||
|
||||
/* Reading */
|
||||
|
||||
get openEntry() {
|
||||
return this.entries.find((oEntry) => oEntry.id === this.openId) || null;
|
||||
}
|
||||
|
||||
/** True when the loaded window reaches the end of the book. */
|
||||
get atWritingEnd() {
|
||||
return !this.hasNewer && (this.openId > 0) && (this.entries.at(-1)?.id === this.openId);
|
||||
}
|
||||
|
||||
get days() {
|
||||
const asDays = new Map();
|
||||
|
||||
for(const oBookmark of this.bookmarks) {
|
||||
const sDay = getDayKey(oBookmark.time, oBookmark.timezone);
|
||||
asDays.set(sDay, (asDays.get(sDay) || 0) + 1);
|
||||
}
|
||||
|
||||
return asDays;
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const asData = await this.api.get('book');
|
||||
this.entries = asData.entries;
|
||||
this.bookmarks = asData.bookmarks;
|
||||
this.hasOlder = asData.has_older;
|
||||
this.hasNewer = asData.has_newer;
|
||||
|
||||
//A "book" response is the newest window, so an entry still marked
|
||||
//open in it is the one this session continues writing into.
|
||||
const oOpen = this.entries.find((oEntry) => oEntry.status === 'open');
|
||||
this.openId = oOpen ? oOpen.id : 0;
|
||||
this.savedContent = oOpen ? oOpen.content : '';
|
||||
this.error = '';
|
||||
}
|
||||
finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadOlder() {
|
||||
if(!this.hasOlder || this.loading) return 0;
|
||||
|
||||
const iCursorId = this.entries[0]?.id || 0;
|
||||
if(iCursorId === 0) return 0;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const asData = await this.api.get('entries', {dir: 'before', id: iCursorId});
|
||||
this.entries = [...asData.entries, ...this.entries];
|
||||
this.hasOlder = asData.has_older;
|
||||
return asData.entries.length;
|
||||
}
|
||||
finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadNewer() {
|
||||
if(!this.hasNewer || this.loading) return 0;
|
||||
|
||||
const iCursorId = this.entries.at(-1)?.id || 0;
|
||||
if(iCursorId === 0) return 0;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const asData = await this.api.get('entries', {dir: 'after', id: iCursorId});
|
||||
this.entries = [...this.entries, ...asData.entries];
|
||||
this.hasNewer = asData.has_newer;
|
||||
return asData.entries.length;
|
||||
}
|
||||
finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-centre the window on an entry that may be far outside it. */
|
||||
async loadAround(iEntryId) {
|
||||
if(this.entries.some((oEntry) => oEntry.id === iEntryId)) return true;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const asData = await this.api.get('entries', {dir: 'around', id: iEntryId});
|
||||
this.entries = asData.entries;
|
||||
this.hasOlder = asData.has_older;
|
||||
this.hasNewer = asData.has_newer;
|
||||
return this.entries.some((oEntry) => oEntry.id === iEntryId);
|
||||
}
|
||||
finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns id of the entry written closest to that day, or 0 */
|
||||
async findEntryAtDate(sDate) {
|
||||
const asData = await this.api.get('date', {date: sDate});
|
||||
return asData.id || 0;
|
||||
}
|
||||
|
||||
/* Writing */
|
||||
|
||||
/**
|
||||
* Claim the entry this session writes into. Resuming a still-open entry
|
||||
* (a reload, a second tab) continues it instead of splitting the thought.
|
||||
*/
|
||||
async startWriting() {
|
||||
const asData = await this.api.post('open_entry');
|
||||
const oEntry = asData.entry;
|
||||
if(!oEntry) return null;
|
||||
|
||||
this.openId = oEntry.id;
|
||||
this.savedContent = oEntry.content;
|
||||
|
||||
if(!this.entries.some((oExisting) => oExisting.id === oEntry.id)) {
|
||||
this.entries.push(oEntry);
|
||||
this.hasNewer = false;
|
||||
}
|
||||
|
||||
if(!this.bookmarks.some((oBookmark) => oBookmark.id === oEntry.id)) {
|
||||
this.bookmarks.push({
|
||||
id: oEntry.id,
|
||||
time: oEntry.time,
|
||||
timezone: oEntry.timezone,
|
||||
status: oEntry.status,
|
||||
preview: ''
|
||||
});
|
||||
}
|
||||
|
||||
return oEntry;
|
||||
}
|
||||
|
||||
write(sContent) {
|
||||
const oEntry = this.openEntry;
|
||||
if(!oEntry) return;
|
||||
|
||||
oEntry.content = sContent;
|
||||
|
||||
const oBookmark = this.bookmarks.find((oItem) => oItem.id === oEntry.id);
|
||||
if(oBookmark) oBookmark.preview = sContent.replace(/\s+/g, ' ').trim().slice(0, 60);
|
||||
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
scheduleSave() {
|
||||
this.saveState = SAVE_PENDING;
|
||||
|
||||
clearTimeout(this.oSaveTimer);
|
||||
this.oSaveTimer = setTimeout(() => this.save(), this.consts.autosave_delay);
|
||||
|
||||
//Someone writing without pause would otherwise never trip the debounce.
|
||||
if(!this.oMaxWaitTimer) {
|
||||
this.oMaxWaitTimer = setTimeout(() => this.save(), this.consts.autosave_max_wait);
|
||||
}
|
||||
}
|
||||
|
||||
get isDirty() {
|
||||
const oEntry = this.openEntry;
|
||||
return (oEntry !== null) && (oEntry.content !== this.savedContent);
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.clearTimers();
|
||||
|
||||
const oEntry = this.openEntry;
|
||||
if(!oEntry || !this.isDirty) {
|
||||
if(this.saveState === SAVE_PENDING) this.saveState = SAVE_SAVED;
|
||||
return;
|
||||
}
|
||||
|
||||
const sContent = oEntry.content;
|
||||
this.saveState = SAVE_SAVING;
|
||||
|
||||
try {
|
||||
const asData = await this.api.post('save_entry', {id: oEntry.id, content: sContent});
|
||||
this.savedContent = sContent;
|
||||
this.savedAt = asData.saved_at || Math.round(Date.now() / 1000);
|
||||
this.error = '';
|
||||
|
||||
//Keystrokes that landed mid-request are still unsaved.
|
||||
this.saveState = this.isDirty ? SAVE_PENDING : SAVE_SAVED;
|
||||
if(this.saveState === SAVE_PENDING) this.scheduleSave();
|
||||
}
|
||||
catch(oError) {
|
||||
this.saveState = SAVE_FAILED;
|
||||
this.error = oError.desc_lang_text || oError.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal the entry as the page goes away. This has to be a beacon: a fetch
|
||||
* issued during unload is cancelled with the document. The content rides
|
||||
* along so the last keystrokes land even if the debounce never fired.
|
||||
*/
|
||||
closeOnUnload() {
|
||||
const oEntry = this.openEntry;
|
||||
if(!oEntry) return;
|
||||
|
||||
this.clearTimers();
|
||||
this.api.beacon('close_entry', {id: oEntry.id, content: oEntry.content});
|
||||
}
|
||||
|
||||
/** Explicit close - used when signing out, where a response is wanted. */
|
||||
async closeEntry() {
|
||||
const oEntry = this.openEntry;
|
||||
if(!oEntry) return;
|
||||
|
||||
this.clearTimers();
|
||||
const iEntryId = oEntry.id;
|
||||
this.openId = 0;
|
||||
|
||||
try {
|
||||
const asData = await this.api.post('close_entry', {id: iEntryId, content: oEntry.content});
|
||||
|
||||
if(asData.discarded) {
|
||||
this.entries = this.entries.filter((oItem) => oItem.id !== iEntryId);
|
||||
this.bookmarks = this.bookmarks.filter((oItem) => oItem.id !== iEntryId);
|
||||
}
|
||||
else {
|
||||
const oBookmark = this.bookmarks.find((oItem) => oItem.id === iEntryId);
|
||||
if(oBookmark) oBookmark.status = 'closed';
|
||||
if(asData.entry) Object.assign(oEntry, asData.entry);
|
||||
}
|
||||
}
|
||||
catch{
|
||||
//Closing is best-effort; the server seals stale entries anyway.
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEntry(iEntryId) {
|
||||
await this.api.post('delete_entry', {id: iEntryId});
|
||||
|
||||
this.entries = this.entries.filter((oItem) => oItem.id !== iEntryId);
|
||||
this.bookmarks = this.bookmarks.filter((oItem) => oItem.id !== iEntryId);
|
||||
if(this.openId === iEntryId) this.openId = 0;
|
||||
}
|
||||
|
||||
clearTimers() {
|
||||
clearTimeout(this.oSaveTimer);
|
||||
clearTimeout(this.oMaxWaitTimer);
|
||||
this.oSaveTimer = null;
|
||||
this.oMaxWaitTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export default class Lang {
|
||||
|
||||
constructor({translations = {}, prefix = '', locale = 'en'} = {}) {
|
||||
this.translations = translations;
|
||||
this.prefix = prefix;
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
get(sLangId = '', params = []) {
|
||||
if(sLangId === '') return '';
|
||||
|
||||
const asParams = Array.isArray(params) ? params : [params];
|
||||
|
||||
if(Object.prototype.hasOwnProperty.call(this.translations, sLangId)) {
|
||||
let sText = this.translations[sLangId];
|
||||
asParams.forEach((sParam, iIndex) => {
|
||||
sText = sText.replace('$' + iIndex, sParam);
|
||||
});
|
||||
return sText;
|
||||
}
|
||||
|
||||
console.warn('Missing translation:', sLangId);
|
||||
return sLangId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Flows the journal into book pages.
|
||||
*
|
||||
* The server stores flat text; where a line breaks and where a page ends
|
||||
* depends entirely on the reader's viewport, so the split happens here. Rather
|
||||
* than guess at wrapping with canvas text metrics - which drift from what the
|
||||
* browser actually does - this measures a real element laid out by the browser
|
||||
* and reads the break positions back out of it with Range. The measurer lives
|
||||
* inside the page column itself, so it inherits the exact width, font and
|
||||
* letter-spacing the finished lines will use, and the textarea that sits on the
|
||||
* writing page wraps identically for free.
|
||||
*/
|
||||
export default class Paginator {
|
||||
|
||||
constructor() {
|
||||
this.oMeasurer = null;
|
||||
this.iLineHeight = 0;
|
||||
this.iLinesPerPage = 0;
|
||||
this.sSignature = '';
|
||||
this.asCache = new Map();
|
||||
this.oRange = document.createRange();
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return (this.oMeasurer !== null) && (this.iLineHeight > 0) && (this.iLinesPerPage > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param oMeasurer Hidden element inside the page text column
|
||||
* @param iLineHeight Height of one ruled line, in px
|
||||
* @param iLinesPerPage How many ruled lines a page holds
|
||||
*/
|
||||
setMetrics(oMeasurer, iLineHeight, iLinesPerPage) {
|
||||
const sSignature = [oMeasurer?.clientWidth || 0, iLineHeight, iLinesPerPage].join(':');
|
||||
if(sSignature === this.sSignature && oMeasurer === this.oMeasurer) return false;
|
||||
|
||||
this.oMeasurer = oMeasurer;
|
||||
this.iLineHeight = iLineHeight;
|
||||
this.iLinesPerPage = iLinesPerPage;
|
||||
this.sSignature = sSignature;
|
||||
|
||||
//Every cached wrap was measured at the old width - none of it survives.
|
||||
this.asCache.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
/**
|
||||
* @param aoEntries Entries in reading order
|
||||
* @returns {{pages: Array, index: Map, lineCount: number}}
|
||||
*/
|
||||
layout(aoEntries) {
|
||||
const asLines = [];
|
||||
const asIndex = new Map();
|
||||
|
||||
for(const oEntry of aoEntries) {
|
||||
const asEntryLines = this.getEntryLines(oEntry);
|
||||
|
||||
asIndex.set(oEntry.id, {
|
||||
firstLine: asLines.length,
|
||||
lastLine: asLines.length + asEntryLines.length - 1
|
||||
});
|
||||
|
||||
asEntryLines.forEach((asLine, iIndex) => {
|
||||
asLines.push({
|
||||
id: oEntry.id,
|
||||
start: asLine.start,
|
||||
end: asLine.end,
|
||||
first: (iIndex === 0)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const iPerPage = Math.max(1, this.iLinesPerPage);
|
||||
const aoPages = [];
|
||||
for(let iLine = 0; iLine < asLines.length; iLine += iPerPage) {
|
||||
aoPages.push(asLines.slice(iLine, iLine + iPerPage));
|
||||
}
|
||||
|
||||
//An empty book is still an open book: one blank spread to write on.
|
||||
if(aoPages.length === 0) aoPages.push([]);
|
||||
|
||||
//A book always shows two pages, so pages come in pairs.
|
||||
if(aoPages.length % 2 === 1) aoPages.push([]);
|
||||
|
||||
//Which page each entry starts and ends on, for bookmarks and for
|
||||
//placing the writing surface.
|
||||
for(const asPosition of asIndex.values()) {
|
||||
asPosition.firstPage = Math.floor(asPosition.firstLine / iPerPage);
|
||||
asPosition.lastPage = Math.floor(asPosition.lastLine / iPerPage);
|
||||
asPosition.firstLineOnPage = asPosition.firstLine % iPerPage;
|
||||
}
|
||||
|
||||
return {pages: aoPages, index: asIndex, lineCount: asLines.length, linesPerPage: iPerPage};
|
||||
}
|
||||
|
||||
getEntryLines(oEntry) {
|
||||
const asCached = this.asCache.get(oEntry.id);
|
||||
if(asCached && asCached.text === oEntry.content) return asCached.lines;
|
||||
|
||||
const asLines = this.wrap(oEntry.content || '');
|
||||
this.asCache.set(oEntry.id, {text: oEntry.content, lines: asLines});
|
||||
return asLines;
|
||||
}
|
||||
|
||||
forget(iEntryId) {
|
||||
this.asCache.delete(iEntryId);
|
||||
}
|
||||
|
||||
/* Measuring */
|
||||
|
||||
/**
|
||||
* Break text into visual lines.
|
||||
* @returns Array of {start, end} character offsets into the text
|
||||
*/
|
||||
wrap(sText) {
|
||||
const asLines = [];
|
||||
const asParagraphs = sText.split('\n');
|
||||
let iBase = 0;
|
||||
|
||||
for(const sParagraph of asParagraphs) {
|
||||
if(sParagraph === '') {
|
||||
asLines.push({start: iBase, end: iBase});
|
||||
}
|
||||
else {
|
||||
for(const asLine of this.wrapParagraph(sParagraph)) {
|
||||
asLines.push({start: iBase + asLine.start, end: iBase + asLine.end});
|
||||
}
|
||||
}
|
||||
|
||||
//+1 for the newline that ended the paragraph
|
||||
iBase += sParagraph.length + 1;
|
||||
}
|
||||
|
||||
return asLines;
|
||||
}
|
||||
|
||||
wrapParagraph(sParagraph) {
|
||||
if(!this.ready) return [{start: 0, end: sParagraph.length}];
|
||||
|
||||
const oMeasurer = this.oMeasurer;
|
||||
oMeasurer.textContent = sParagraph;
|
||||
|
||||
const iLineCount = Math.max(1, Math.round(oMeasurer.getBoundingClientRect().height / this.iLineHeight));
|
||||
if(iLineCount === 1) return [{start: 0, end: sParagraph.length}];
|
||||
|
||||
const oNode = oMeasurer.firstChild;
|
||||
const iOrigin = this.getCharTop(oNode, 0, sParagraph.length);
|
||||
|
||||
//The vertical position of a character never decreases as you move
|
||||
//forward through wrapped left-to-right text, so each line's first
|
||||
//character can be found by binary search instead of scanning.
|
||||
const asStarts = [0];
|
||||
let iFrom = 1;
|
||||
|
||||
for(let iLine = 1; iLine < iLineCount; iLine++) {
|
||||
const iThreshold = (iLine - 0.5) * this.iLineHeight;
|
||||
let iLow = iFrom;
|
||||
let iHigh = sParagraph.length - 1;
|
||||
let iFound = sParagraph.length;
|
||||
|
||||
while(iLow <= iHigh) {
|
||||
const iMid = (iLow + iHigh) >> 1;
|
||||
if((this.getCharTop(oNode, iMid, sParagraph.length) - iOrigin) >= iThreshold) {
|
||||
iFound = iMid;
|
||||
iHigh = iMid - 1;
|
||||
}
|
||||
else iLow = iMid + 1;
|
||||
}
|
||||
|
||||
//Defensive: a mis-measured height would otherwise emit empty lines
|
||||
//forever. Stop early rather than produce a broken page.
|
||||
if(iFound >= sParagraph.length) break;
|
||||
|
||||
asStarts.push(iFound);
|
||||
iFrom = iFound + 1;
|
||||
}
|
||||
|
||||
return asStarts.map((iStart, iIndex) => ({
|
||||
start: iStart,
|
||||
end: (iIndex + 1 < asStarts.length) ? asStarts[iIndex + 1] : sParagraph.length
|
||||
}));
|
||||
}
|
||||
|
||||
getCharTop(oNode, iOffset, iLength) {
|
||||
this.oRange.setStart(oNode, iOffset);
|
||||
this.oRange.setEnd(oNode, Math.min(iOffset + 1, iLength));
|
||||
return this.oRange.getBoundingClientRect().top;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Every entry carries the timezone it was written in, and every stamp is
|
||||
* formatted in that timezone rather than in the reader's current one - a
|
||||
* journal written on a trip should still read as the local time of the moment.
|
||||
* Timestamps cross the wire as UNIX seconds, so nothing here parses a string.
|
||||
*/
|
||||
|
||||
const asFormatterCache = new Map();
|
||||
|
||||
function getFormatter(sLocale, sTimezone, asOptions) {
|
||||
const sKey = sLocale + '|' + sTimezone + '|' + JSON.stringify(asOptions);
|
||||
|
||||
if(!asFormatterCache.has(sKey)) {
|
||||
let oFormatter;
|
||||
try {
|
||||
oFormatter = new Intl.DateTimeFormat(sLocale, {...asOptions, timeZone: sTimezone});
|
||||
}
|
||||
catch{
|
||||
//An unknown IANA name (renamed zone, hand-edited row) must not take
|
||||
//the whole book down - fall back to the browser's own zone.
|
||||
oFormatter = new Intl.DateTimeFormat(sLocale, asOptions);
|
||||
}
|
||||
asFormatterCache.set(sKey, oFormatter);
|
||||
}
|
||||
|
||||
return asFormatterCache.get(sKey);
|
||||
}
|
||||
|
||||
export function getBrowserTimezone() {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
catch{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' for the given instant, as seen in the given timezone. */
|
||||
export function getDayKey(iUnix, sTimezone) {
|
||||
return getFormatter('en-CA', sTimezone, {year: 'numeric', month: '2-digit', day: '2-digit'}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' for a local Date, without the UTC shift toISOString() adds. */
|
||||
export function getLocalDayKey(oDate) {
|
||||
const sMonth = String(oDate.getMonth() + 1).padStart(2, '0');
|
||||
const sDay = String(oDate.getDate()).padStart(2, '0');
|
||||
return oDate.getFullYear() + '-' + sMonth + '-' + sDay;
|
||||
}
|
||||
|
||||
export function formatDate(iUnix, sTimezone, sLocale) {
|
||||
return getFormatter(sLocale, sTimezone, {day: 'numeric', month: 'short', year: 'numeric'}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
export function formatShortDate(iUnix, sTimezone, sLocale) {
|
||||
return getFormatter(sLocale, sTimezone, {day: 'numeric', month: 'short'}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
export function formatTime(iUnix, sTimezone, sLocale) {
|
||||
return getFormatter(sLocale, sTimezone, {hour: '2-digit', minute: '2-digit'}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
export function formatWeekday(iUnix, sTimezone, sLocale) {
|
||||
return getFormatter(sLocale, sTimezone, {weekday: 'long'}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
export function formatFull(iUnix, sTimezone, sLocale) {
|
||||
return getFormatter(sLocale, sTimezone, {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
}).format(iUnix * 1000);
|
||||
}
|
||||
|
||||
export function formatMonthTitle(oDate, sLocale) {
|
||||
return new Intl.DateTimeFormat(sLocale, {month: 'long', year: 'numeric'}).format(oDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekday initials starting on Monday, in the reader's locale.
|
||||
*/
|
||||
export function getWeekdayInitials(sLocale) {
|
||||
const oFormatter = new Intl.DateTimeFormat(sLocale, {weekday: 'short'});
|
||||
const asLabels = [];
|
||||
|
||||
//2024-01-01 was a Monday, which anchors the week without hard-coding names.
|
||||
for(let iDay = 0; iDay < 7; iDay++) {
|
||||
asLabels.push(oFormatter.format(new Date(Date.UTC(2024, 0, 1 + iDay))));
|
||||
}
|
||||
|
||||
return asLabels;
|
||||
}
|
||||
|
||||
/** Short "how long ago", used by the autosave indicator only. */
|
||||
export function formatSince(iUnix, sLocale, sJustNow) {
|
||||
const iSeconds = Math.round(Date.now() / 1000) - iUnix;
|
||||
if(iSeconds < 45) return sJustNow;
|
||||
|
||||
const oFormatter = new Intl.RelativeTimeFormat(sLocale, {numeric: 'auto'});
|
||||
if(iSeconds < 3600) return oFormatter.format(-Math.round(iSeconds / 60), 'minute');
|
||||
if(iSeconds < 86400) return oFormatter.format(-Math.round(iSeconds / 3600), 'hour');
|
||||
return oFormatter.format(-Math.round(iSeconds / 86400), 'day');
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Header: the shelf above the book. Deliberately thin - the book is the app */
|
||||
.app__header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var.$block-spacing;
|
||||
padding: 0.6rem clamp(0.75rem, 2vw, 1.5rem);
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.app__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* The logo is dark roast ink on a transparent bubble - drawn for paper, not for
|
||||
* the desk. Rather than sit it on a light patch and break the surface, it is
|
||||
* re-inked in cream: brightness(0) flattens it to a silhouette, invert lifts it
|
||||
* to white, and the sepia/hue pass warms that back to the colour of the pages.
|
||||
* Its full colour is kept for the sign-in leaf, which is paper. */
|
||||
.app__logo {
|
||||
height: 3.3rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
//brightness(0) flattens the artwork to a silhouette, invert lifts it to
|
||||
//white, and the short sepia pass warms that to the colour of the pages.
|
||||
//Kept deliberately simple - a longer chain only muddies it.
|
||||
filter:
|
||||
brightness(0)
|
||||
invert(1)
|
||||
sepia(0.22)
|
||||
saturate(1.5)
|
||||
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.app__tagline {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 238, 210, 0.58);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app__tools {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var.$elem-spacing;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Main: the rail hangs off the right edge of the book, so they share a row */
|
||||
.app__desk {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
padding: 0 clamp(0.5rem, 2vw, 2rem) clamp(0.75rem, 2.5vh, 2rem);
|
||||
}
|
||||
|
||||
.app__book {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: var.$book-max-width;
|
||||
}
|
||||
|
||||
/* Flex, so the rail inside inherits a definite height and scrolls internally.
|
||||
* Left as a plain block it grows to the height of every tab in the book, which
|
||||
* makes the whole document scrollable - and then focusing the writing surface
|
||||
* scrolls the book itself out of view. */
|
||||
.app__rail {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* A popover anchored under a header button */
|
||||
.app__popover {
|
||||
position: absolute;
|
||||
top: calc(100% - 0.2rem);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
background-color: color.$paper;
|
||||
border-radius: var.$block-radius;
|
||||
box-shadow: var.$shadow-panel;
|
||||
border: 1px solid color.$paper-deep;
|
||||
}
|
||||
|
||||
.app__popover-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Loading & error strips */
|
||||
.app__notice {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var.$text-spacing;
|
||||
max-width: min(38rem, 90vw);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: #fff4e2;
|
||||
background-color: rgba(29, 21, 15, 0.92);
|
||||
box-shadow: var.$shadow-panel;
|
||||
}
|
||||
|
||||
.app__notice--bad {
|
||||
background-color: color.$ribbon-deep;
|
||||
}
|
||||
|
||||
.app__notice-close {
|
||||
color: rgba(255, 244, 226, 0.6);
|
||||
display: inline-flex;
|
||||
|
||||
&:hover {
|
||||
color: #fff4e2;
|
||||
}
|
||||
}
|
||||
|
||||
/* The account menu */
|
||||
.account-menu {
|
||||
min-width: 15rem;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.account-menu__who {
|
||||
padding: 0.5rem 0.65rem 0.55rem;
|
||||
border-bottom: 1px solid color.$paper-edge;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.account-menu__name {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.1;
|
||||
color: color.$ink;
|
||||
}
|
||||
|
||||
.account-menu__email {
|
||||
font-size: 0.78rem;
|
||||
color: color.$ink-soft;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: var.$block-radius;
|
||||
text-align: left;
|
||||
color: color.$ink;
|
||||
transition: background-color var.$trans-quick;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
@use "sass:color" as sass-color;
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
/* Everything on a page is a multiple of one ruled line.
|
||||
*
|
||||
* `--book-line` is the height of that line and the single source of truth for
|
||||
* the ruling, the text leading, the stamp positions and the caret. JS reads its
|
||||
* resolved pixel value back with getComputedStyle to paginate, and writes
|
||||
* `--book-lines` once it knows how many whole lines fit in the page body - so
|
||||
* the paper never ends on half a rule. */
|
||||
.book {
|
||||
--book-line: #{var.$line-fallback};
|
||||
--book-lines: 12;
|
||||
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
//Stretch, not centre: the pages have to inherit a definite height, since
|
||||
//how many ruled lines fit on one is measured from it.
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* The closed shell: the covers and the block of pages the spread sits on top of */
|
||||
.book__shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: var.$book-max-width;
|
||||
border-radius: var.$book-radius;
|
||||
background-color: color.$paper-edge;
|
||||
box-shadow: var.$shadow-page;
|
||||
|
||||
//The page block: a few stacked cut edges peeking out below and to the sides
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -0.35rem -0.5rem -0.55rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var.$book-radius + 2px);
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
color.$paper-deep 0,
|
||||
color.$paper-edge 22%,
|
||||
color.$paper-deep 40%,
|
||||
color.$paper-edge 62%,
|
||||
color.$paper-deep 100%
|
||||
);
|
||||
box-shadow: 0 0.9rem 1.6rem color.$shadow-deep;
|
||||
}
|
||||
}
|
||||
|
||||
.book__spread {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
border-radius: var.$book-radius;
|
||||
overflow: hidden;
|
||||
background-color: color.$paper;
|
||||
|
||||
//Depth for the turning leaf. Long, because a book seen from a chair is
|
||||
//nearly flat on - a short perspective makes the page fan out like a card
|
||||
//trick rather than lift off the gutter.
|
||||
perspective: 2600px;
|
||||
perspective-origin: 50% 45%;
|
||||
}
|
||||
|
||||
/* The gutter. Two paper surfaces meeting is the one place the book needs real
|
||||
* shading: without it the spread reads as a single flat sheet. */
|
||||
.book__spine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: var.$spine-width;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
rgba(120, 96, 62, 0) 0%,
|
||||
rgba(120, 96, 62, 0.1) 32%,
|
||||
rgba(96, 74, 46, 0.3) 47%,
|
||||
rgba(72, 54, 32, 0.42) 50%,
|
||||
rgba(96, 74, 46, 0.3) 53%,
|
||||
rgba(120, 96, 62, 0.1) 68%,
|
||||
rgba(120, 96, 62, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* A single page */
|
||||
.page {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var.$page-padding clamp(1rem, 2.2vw, 2.25rem);
|
||||
|
||||
//Paper, lit from the outside edge and darkening into the gutter
|
||||
background-color: color.$paper;
|
||||
background-image:
|
||||
radial-gradient(120% 80% at 50% 0%, rgba(255, 255, 255, 0.5), transparent 55%),
|
||||
repeating-linear-gradient(
|
||||
117deg,
|
||||
rgba(120, 96, 62, 0.012) 0 2px,
|
||||
rgba(255, 255, 255, 0.02) 2px 5px
|
||||
);
|
||||
}
|
||||
|
||||
.page--left {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.45), transparent 30%),
|
||||
linear-gradient(to right, transparent 70%, rgba(140, 112, 74, 0.09) 100%),
|
||||
repeating-linear-gradient(
|
||||
117deg,
|
||||
rgba(120, 96, 62, 0.012) 0 2px,
|
||||
rgba(255, 255, 255, 0.02) 2px 5px
|
||||
);
|
||||
box-shadow: inset -1px 0 0 rgba(140, 112, 74, 0.12);
|
||||
}
|
||||
|
||||
.page--right {
|
||||
background-image:
|
||||
linear-gradient(to left, rgba(255, 255, 255, 0.45), transparent 30%),
|
||||
linear-gradient(to left, transparent 70%, rgba(140, 112, 74, 0.09) 100%),
|
||||
repeating-linear-gradient(
|
||||
117deg,
|
||||
rgba(120, 96, 62, 0.012) 0 2px,
|
||||
rgba(255, 255, 255, 0.02) 2px 5px
|
||||
);
|
||||
}
|
||||
|
||||
/* The page body: margin column, then the ruled text column */
|
||||
.page__body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page__margin {
|
||||
flex: 0 0 var.$page-margin-width;
|
||||
position: relative;
|
||||
height: calc(var(--book-lines) * var(--book-line));
|
||||
padding-right: 0.7rem;
|
||||
|
||||
//The red margin rule every school notebook has
|
||||
border-right: 1px solid color.$rule-margin;
|
||||
}
|
||||
|
||||
/* Where an entry begins, the margin says when it was written */
|
||||
.page__stamp {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
width: calc(100% - 0.7rem);
|
||||
text-align: right;
|
||||
font-family: var.$font-hand;
|
||||
line-height: 1.05;
|
||||
color: color.$ink-soft;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page__stamp-date {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page__stamp-time {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
|
||||
/* The text column. Height is an exact number of rules, so the ruling always
|
||||
* ends flush with the bottom of the column. */
|
||||
.page__column {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
height: calc(var(--book-lines) * var(--book-line));
|
||||
padding-left: 0.9rem;
|
||||
|
||||
//The ruling. Each rule sits at the bottom of its line's band.
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent calc(var(--book-line) - 1px),
|
||||
color.$rule calc(var(--book-line) - 1px),
|
||||
color.$rule var(--book-line)
|
||||
);
|
||||
}
|
||||
|
||||
/* Shared text metrics.
|
||||
*
|
||||
* The rendered lines, the hidden measurer and the input all have to wrap
|
||||
* identically - the measurer is what decides where the breaks are, the lines
|
||||
* are what the reader sees, and the input is what the arrow keys move through.
|
||||
* Any drift between the three shows up as a caret in the wrong place, so the
|
||||
* 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);
|
||||
line-height: var(--book-line);
|
||||
letter-spacing: 0.005em;
|
||||
word-spacing: 0.02em;
|
||||
font-variant-ligatures: none;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.page__lines {
|
||||
@include page-text-metrics;
|
||||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: color.$ink;
|
||||
}
|
||||
|
||||
/* One visual line, already broken by the paginator - so it must never re-wrap */
|
||||
.page__line {
|
||||
display: block;
|
||||
height: var(--book-line);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* An entry that was closed reads as past writing: same hand, a touch lighter */
|
||||
.page__line--closed {
|
||||
color: sass-color.mix(color.$ink, color.$paper, 88%);
|
||||
}
|
||||
|
||||
/* The hidden element the paginator measures in.
|
||||
*
|
||||
* Book.vue owns it and positions it inline over the real text column, at that
|
||||
* column's measured width - so it wraps at exactly the width the finished lines
|
||||
* will have, while staying one stable element across page turns. */
|
||||
.page__measure {
|
||||
@include page-text-metrics;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* The writing surface.
|
||||
*
|
||||
* A textarea cannot flow from the left page onto the right one, so it is not
|
||||
* what you look at: it is only where the keystrokes, the selection and the
|
||||
* arrow keys live. Its text is transparent and the book draws the glyphs, the
|
||||
* caret and the selection itself. It is sized and styled exactly like a page
|
||||
* column, which means its own soft wrapping matches the paginator's - so Up and
|
||||
* Down still move by the visual lines the reader can actually see. */
|
||||
.page__input {
|
||||
@include page-text-metrics;
|
||||
|
||||
//Placed inline by Book.vue over whichever column the caret is on, so the
|
||||
//element itself survives every page turn and keeps its focus and selection.
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
|
||||
//Clicks are handled by the page, which maps them to a character offset and
|
||||
//moves the real caret there.
|
||||
pointer-events: none;
|
||||
|
||||
&::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The caret the book draws for itself */
|
||||
.page__caret {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: calc(var(--book-line) * 0.66);
|
||||
margin-top: calc(var(--book-line) * 0.16);
|
||||
background-color: color.$ribbon;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
animation: caret-blink 1.1s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50%,
|
||||
95% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection, drawn per visual line since the native one is invisible */
|
||||
.page__selection {
|
||||
position: absolute;
|
||||
height: calc(var(--book-line) * 0.86);
|
||||
margin-top: calc(var(--book-line) * 0.07);
|
||||
background-color: rgba(180, 126, 65, 0.3);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Clicking anywhere on the writing page puts the caret there */
|
||||
.page--writable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Page furniture */
|
||||
.page__foot {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var.$elem-spacing;
|
||||
padding-top: 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
color: color.$ink-faint;
|
||||
min-height: 1.6rem;
|
||||
}
|
||||
|
||||
.page__number {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 1rem;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
|
||||
.page--left .page__foot {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.page__hint {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 1.05rem;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
|
||||
/* The placeholder, shown on a page with nothing written on it yet */
|
||||
.page__placeholder {
|
||||
@include page-text-metrics;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
color: color.$ink-ghost;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Turning.
|
||||
*
|
||||
* A real leaf, hinged on the gutter. Going forward it is the right-hand page
|
||||
* that lifts and swings left - the direction your hand actually moves - and it
|
||||
* carries the next left-hand page on its back, so what lands is what you then
|
||||
* read. Going back is the mirror.
|
||||
*
|
||||
* The spread underneath shows a mix while this runs (see visiblePages): the
|
||||
* side the leaf lifted from keeps its old page until the leaf covers it.
|
||||
*/
|
||||
.book__leaf {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
z-index: 7;
|
||||
pointer-events: none;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
//Right page sweeping left across the gutter
|
||||
@keyframes leaf-forward {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotateY(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
//Left page sweeping right across the gutter
|
||||
@keyframes leaf-back {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.book__leaf-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
backface-visibility: hidden;
|
||||
background-color: color.$paper;
|
||||
box-shadow: 0 0 2rem rgba(20, 14, 9, 0.35);
|
||||
|
||||
//The page fills the leaf rather than half a spread
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
//The back of a leaf is only seen once it has swung past upright
|
||||
.book__leaf-face--back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes leaf-shade-out {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leaf-shade-in {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes gutter-deepen {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.book__spread--single.book__turning--back .page {
|
||||
animation: page-in-from-left var.$trans-mid ease-out;
|
||||
}
|
||||
|
||||
@keyframes page-in-from-right {
|
||||
from {
|
||||
transform: translateX(14%);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-in-from-left {
|
||||
from {
|
||||
transform: translateX(-14%);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Turn controls, tucked into the outer edge of each page */
|
||||
.book__turner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 3.4rem;
|
||||
color: color.$ink-faint;
|
||||
border-radius: var.$block-radius;
|
||||
transition: color var.$trans-quick, background-color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: color.$ribbon;
|
||||
background-color: rgba(140, 112, 74, 0.08);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.book__turner--back {
|
||||
left: 0.1rem;
|
||||
}
|
||||
|
||||
.book__turner--forward {
|
||||
right: 0.1rem;
|
||||
}
|
||||
|
||||
/* The ribbon, hanging out of the gutter. Marks the page being written on. */
|
||||
.book__ribbon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - 0.55rem);
|
||||
width: 1.1rem;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(to right, color.$ribbon-deep, color.$ribbon 45%, color.$ribbon-deep);
|
||||
box-shadow: 0 0 0.4rem rgba(20, 14, 9, 0.3);
|
||||
transition: height var.$trans-mid ease-out, opacity var.$trans-quick;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -0.7rem;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0.75rem;
|
||||
background-color: color.$ribbon;
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 55%, 0 100%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
/* The calendar. A month at a time, with a mark under every day that has
|
||||
* writing on it - so it doubles as a picture of how the journal is kept. */
|
||||
.calendar {
|
||||
width: 19rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.calendar__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var.$elem-spacing;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar__month {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: color.$ink;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.calendar__step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: var.$block-radius;
|
||||
color: color.$ink-soft;
|
||||
transition: background-color var.$trans-quick, color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: color.$ink;
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.calendar__weekday {
|
||||
text-align: center;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: color.$ink-faint;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar__day {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var.$block-radius;
|
||||
color: color.$ink;
|
||||
transition: background-color var.$trans-quick, color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: color.$ink-faint;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar__day--outside {
|
||||
color: color.$ink-faint;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* A day with writing on it. The dot is the affordance; the weight change is
|
||||
* what you actually notice when scanning a month. */
|
||||
.calendar__day--written {
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0.18rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
border-radius: 50%;
|
||||
background-color: color.$ribbon;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar__day--today {
|
||||
box-shadow: inset 0 0 0 1px color.$caramel;
|
||||
}
|
||||
|
||||
.calendar__day--current {
|
||||
color: #fff6e6;
|
||||
background-color: color.$ribbon;
|
||||
|
||||
&:hover {
|
||||
background-color: color.$ribbon;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: #fff6e6;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var.$elem-spacing;
|
||||
margin-top: 0.55rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid color.$paper-edge;
|
||||
}
|
||||
|
||||
.calendar__link {
|
||||
font-size: 0.78rem;
|
||||
color: color.$ink-soft;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: var.$block-radius;
|
||||
|
||||
&:hover {
|
||||
color: color.$ink;
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar__count {
|
||||
font-size: 0.72rem;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* The palette is taken from the logo, not chosen alongside it.
|
||||
*
|
||||
* src/images/logo.png is dark-roast lettering in a thought bubble with a cup of
|
||||
* coffee; sampling it gives one dominant near-black brown (#2c2114) and a run of
|
||||
* caramels from #795731 up to #b47e41. Those are the colours below. The book is
|
||||
* the same idea in furniture: a coffee-dark desk, cream paper, and caramel for
|
||||
* anything that has to catch the eye.
|
||||
*
|
||||
* There is no pure grey and no pure black anywhere - paper and lamplight do not
|
||||
* have any, and neither does the logo. */
|
||||
|
||||
//The desk the book lies on - straight from the logo's darkest inks
|
||||
$desk-deep: #1a140d;
|
||||
$desk: #2c2114;
|
||||
$desk-edge: #44321e;
|
||||
|
||||
//Paper. -shade is the tint towards the spine, -edge the cut edge of the stack
|
||||
$paper: #f8f2e4;
|
||||
$paper-shade: #f0e7d3;
|
||||
$paper-edge: #e0d3b8;
|
||||
$paper-deep: #cdbb99;
|
||||
|
||||
//Ink, in the three weights the book uses: what you wrote, what the book says
|
||||
//about it, and what is barely there
|
||||
$ink: #2e2822;
|
||||
$ink-soft: #6d6358;
|
||||
$ink-faint: #a29684;
|
||||
$ink-ghost: rgba(46, 40, 34, 0.38);
|
||||
|
||||
//Ruling. The horizontal rules are cold on purpose - they are the one thing on
|
||||
//the page that is not ink, and that contrast is what makes paper read as paper
|
||||
$rule: rgba(90, 120, 150, 0.22);
|
||||
$rule-margin: rgba(168, 118, 62, 0.5);
|
||||
|
||||
//Accents: the coffee and the steam swirl
|
||||
$caramel: #b47e41;
|
||||
$caramel-deep: #795731;
|
||||
$caramel-light: #d0a06a;
|
||||
|
||||
//The ribbon marking the page being written on
|
||||
$ribbon: $caramel-deep;
|
||||
$ribbon-deep: #5c4428;
|
||||
|
||||
//Feedback. Brick rather than red, so a warning still belongs to the palette
|
||||
$ok: #5c6b3a;
|
||||
$warn: #a8763e;
|
||||
$bad: #9c4a34;
|
||||
|
||||
//Overlays
|
||||
$veil: rgba(20, 14, 9, 0.74);
|
||||
$shadow-soft: rgba(20, 14, 9, 0.18);
|
||||
$shadow-deep: rgba(20, 14, 9, 0.45);
|
||||
@@ -0,0 +1,151 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var.$font-ui;
|
||||
font-size: 16px;
|
||||
color: color.$ink;
|
||||
background-color: color.$desk-deep;
|
||||
|
||||
//The desk: a warm pool of lamplight falling on wood, with the grain done as
|
||||
//a pair of very low-contrast repeating gradients rather than a bitmap.
|
||||
background-image:
|
||||
radial-gradient(ellipse 120% 90% at 50% -10%, rgba(255, 226, 178, 0.16), transparent 60%),
|
||||
repeating-linear-gradient(
|
||||
92deg,
|
||||
rgba(0, 0, 0, 0.05) 0 3px,
|
||||
rgba(255, 255, 255, 0.014) 3px 7px,
|
||||
rgba(0, 0, 0, 0.035) 7px 11px
|
||||
),
|
||||
linear-gradient(160deg, color.$desk-edge 0%, color.$desk 45%, color.$desk-deep 100%);
|
||||
background-attachment: fixed;
|
||||
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
//Checkboxes and the like belong to the palette, not to the browser
|
||||
accent-color: color.$caramel-deep;
|
||||
}
|
||||
|
||||
/* Buttons that sit on the desk rather than on the paper: brass-ish, restrained */
|
||||
.desk-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var.$text-spacing;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: var.$block-radius;
|
||||
color: rgba(255, 244, 224, 0.82);
|
||||
background-color: rgba(255, 240, 214, 0.07);
|
||||
border: 1px solid rgba(255, 240, 214, 0.14);
|
||||
transition: background-color var.$trans-quick, color var.$trans-quick, border-color var.$trans-quick;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #fff8ea;
|
||||
background-color: rgba(255, 240, 214, 0.14);
|
||||
border-color: rgba(255, 240, 214, 0.28);
|
||||
}
|
||||
|
||||
&[aria-expanded="true"],
|
||||
&.is-active {
|
||||
color: #fff8ea;
|
||||
background-color: rgba(255, 240, 214, 0.18);
|
||||
border-color: rgba(255, 240, 214, 0.34);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.desk-button--icon {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
/* Text inputs on paper */
|
||||
.paper-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
color: color.$ink;
|
||||
background-color: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid color.$paper-deep;
|
||||
border-radius: var.$block-radius;
|
||||
transition: border-color var.$trans-quick, box-shadow var.$trans-quick;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: color.$ribbon;
|
||||
box-shadow: 0 0 0 3px rgba(163, 55, 47, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.paper-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: color.$ink-soft;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* Screen-reader-only, for the labels the book conveys visually */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid color.$caramel;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
/* A phone cannot hold a spread, so it holds a page.
|
||||
*
|
||||
* Book.vue decides that (it paginates one page per view instead of two), and
|
||||
* the styles here just take the second half of the furniture away: no gutter,
|
||||
* no ribbon down the middle, and the rail becomes a drawer instead of tabs
|
||||
* sticking out of a book that no longer has room beside it. */
|
||||
|
||||
.book__spread--single {
|
||||
.book__spine,
|
||||
.book__ribbon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown only where the rail has to be summoned */
|
||||
.app__rail-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Narrow desks give the rail less room, but never so little that the date and
|
||||
* time stop being readable - that is the whole content of a bookmark. */
|
||||
@media (max-width: var.$narrow) {
|
||||
.rail {
|
||||
width: 9.5rem;
|
||||
}
|
||||
|
||||
.rail__preview {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: var.$mobile) {
|
||||
body {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app__header {
|
||||
padding: 0.5rem 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app__logo {
|
||||
height: 2.2rem;
|
||||
}
|
||||
|
||||
.app__tagline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The dot alone still says saved / saving / unsaved, and the header has no
|
||||
* room for the sentence that goes with it. */
|
||||
.saver span:not(.saver__dot) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.saver {
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
|
||||
.app__desk {
|
||||
padding: 0 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.app__rail-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 1.1rem 0.85rem;
|
||||
}
|
||||
|
||||
.page__margin {
|
||||
flex-basis: 3.1rem;
|
||||
padding-right: 0.4rem;
|
||||
}
|
||||
|
||||
.page__stamp {
|
||||
right: 0.4rem;
|
||||
width: calc(100% - 0.4rem);
|
||||
}
|
||||
|
||||
.page__stamp-date {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.page__stamp-time {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.page__column,
|
||||
.page__measure,
|
||||
.page__input,
|
||||
.page__placeholder {
|
||||
padding-left: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.page__column {
|
||||
padding-left: 0.55rem;
|
||||
}
|
||||
|
||||
.page__measure,
|
||||
.page__input,
|
||||
.page__placeholder {
|
||||
left: 0.55rem;
|
||||
}
|
||||
|
||||
.book__turner {
|
||||
width: 2rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
|
||||
/* The rail, as a drawer off the right edge */
|
||||
.app__rail {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 90;
|
||||
width: min(17rem, 82vw);
|
||||
padding: 0.75rem 0.5rem 0.75rem 0.75rem;
|
||||
background-color: rgba(29, 21, 15, 0.96);
|
||||
box-shadow: var.$shadow-panel;
|
||||
transform: translateX(100%);
|
||||
transition: transform var.$trans-mid ease-out;
|
||||
}
|
||||
|
||||
.app__rail--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.rail {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rail__tab {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
border-radius: var.$block-radius;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&.rail__tab--current {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rail__empty {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
width: min(19rem, calc(100vw - 1.5rem));
|
||||
}
|
||||
|
||||
.app__popover {
|
||||
right: -0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short viewports: the header is the only thing that can give ground */
|
||||
@media (max-height: 560px) {
|
||||
.app__header {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.app__logo {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a journal should give paper, not an app */
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.app__header,
|
||||
.app__rail,
|
||||
.book__turner,
|
||||
.book__ribbon,
|
||||
.page__input,
|
||||
.page__caret {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.book__shell,
|
||||
.book__spread {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.book__shell::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
/* Overlays: the sign-in card, and the settings sheet.
|
||||
* Both are the same object - a single leaf of paper on the desk. */
|
||||
.veil {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var.$block-spacing;
|
||||
background-color: color.$veil;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.leaf {
|
||||
width: min(26rem, 100%);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
background-color: color.$paper;
|
||||
border-radius: var.$block-radius;
|
||||
box-shadow: var.$shadow-panel;
|
||||
|
||||
//A cut edge along the left, so it reads as torn from the book
|
||||
background-image: linear-gradient(to right, color.$paper-edge 0 3px, transparent 3px);
|
||||
}
|
||||
|
||||
.leaf--wide {
|
||||
width: min(34rem, 100%);
|
||||
}
|
||||
|
||||
.leaf__head {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.leaf__logo {
|
||||
display: block;
|
||||
width: min(15rem, 70%);
|
||||
height: auto;
|
||||
margin: 0 0 0.6rem -0.4rem;
|
||||
}
|
||||
|
||||
.leaf__title {
|
||||
margin: 0;
|
||||
font-family: var.$font-hand;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
color: color.$ink;
|
||||
}
|
||||
|
||||
.leaf__sub {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.85rem;
|
||||
color: color.$ink-soft;
|
||||
}
|
||||
|
||||
.leaf__row {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.leaf__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var.$elem-spacing;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.leaf__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
color: color.$ink-soft;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leaf__error {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var.$block-radius;
|
||||
font-size: 0.85rem;
|
||||
color: color.$ribbon-deep;
|
||||
background-color: rgba(163, 55, 47, 0.09);
|
||||
border-left: 2px solid color.$ribbon;
|
||||
}
|
||||
|
||||
.leaf__note {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: color.$ink-soft;
|
||||
}
|
||||
|
||||
/* The one solid button in the app */
|
||||
.ink-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var.$text-spacing;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: var.$block-radius;
|
||||
color: #fff6e6;
|
||||
background-color: color.$ribbon;
|
||||
box-shadow: var.$shadow-elem;
|
||||
transition: background-color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
background-color: color.$ribbon-deep;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.text-button {
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: var.$block-radius;
|
||||
color: color.$ink-soft;
|
||||
transition: color var.$trans-quick, background-color var.$trans-quick;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: color.$ink;
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
}
|
||||
|
||||
.text-button--bad {
|
||||
color: color.$ribbon;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: color.$ribbon-deep;
|
||||
background-color: rgba(163, 55, 47, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* The autosave indicator. Lives in the header and must never shout: it is
|
||||
* ambient reassurance, not a notification. */
|
||||
.saver {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(255, 238, 210, 0.5);
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
transition: color var.$trans-quick;
|
||||
}
|
||||
|
||||
.saver--saving,
|
||||
.saver--pending {
|
||||
color: rgba(255, 238, 210, 0.75);
|
||||
}
|
||||
|
||||
.saver--failed {
|
||||
color: #f0b6ae;
|
||||
}
|
||||
|
||||
.saver__dot {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.saver--saving .saver__dot {
|
||||
animation: saver-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes saver-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
@use "@styles/color";
|
||||
@use "@styles/var";
|
||||
|
||||
/* The bookmark rail.
|
||||
*
|
||||
* One tab per entry, in writing order, sliding out from behind the right edge
|
||||
* of the book. The negative margin is what sells it: the tabs start underneath
|
||||
* the page block and only their labelled ends stick out, the way a stack of
|
||||
* sticky tabs would. */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
//The tabs scroll within the rail; the rail never grows the page
|
||||
height: 100%;
|
||||
width: var.$tab-width;
|
||||
margin-left: 0;
|
||||
padding: 0.35rem 0;
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* The tabs say what they are; a heading would only overflow the narrow rail
|
||||
* and print itself across the edge of the book. Kept for screen readers. */
|
||||
.rail__title {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail__list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* A tab.
|
||||
*
|
||||
* Only its bound edge is tucked under the page block - the rest stands clear,
|
||||
* because a bookmark whose date you cannot read until you hover it is not a
|
||||
* bookmark. Hovering slides it further out to bring the preview into the light.
|
||||
*/
|
||||
.rail__tab {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: var.$tab-height;
|
||||
margin-left: calc(-1 * var.$tab-tuck);
|
||||
padding: 0.4rem 0.65rem 0.45rem calc(var.$tab-tuck + 0.55rem);
|
||||
text-align: left;
|
||||
|
||||
color: color.$ink-soft;
|
||||
background-color: color.$paper-shade;
|
||||
//The shadowed strip that reads as the part still inside the book
|
||||
background-image: linear-gradient(to right, rgba(90, 68, 40, 0.22), rgba(90, 68, 40, 0.04) var.$tab-tuck, transparent calc(var.$tab-tuck + 0.5rem));
|
||||
border-radius: 0 var.$block-radius var.$block-radius 0;
|
||||
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.3);
|
||||
|
||||
transition: transform var.$trans-mid ease-out, background-color var.$trans-quick, color var.$trans-quick;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateX(0.45rem);
|
||||
background-color: color.$paper;
|
||||
color: color.$ink;
|
||||
}
|
||||
}
|
||||
|
||||
/* The entry the book is currently showing */
|
||||
.rail__tab--current {
|
||||
transform: translateX(0.3rem);
|
||||
background-color: color.$paper;
|
||||
color: color.$ink;
|
||||
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.34), inset -0.2rem 0 0 color.$caramel;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateX(0.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* The entry still being written */
|
||||
.rail__tab--open {
|
||||
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.34), inset -0.2rem 0 0 color.$ribbon;
|
||||
}
|
||||
|
||||
.rail__tab--current.rail__tab--open {
|
||||
box-shadow: 0.12rem 0.15rem 0.6rem rgba(20, 14, 9, 0.4), inset -0.2rem 0 0 color.$ribbon;
|
||||
}
|
||||
|
||||
.rail__date {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
font-family: var.$font-hand;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rail__day {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.rail__time {
|
||||
font-size: 1rem;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
|
||||
/* Only visible once the tab has slid out */
|
||||
.rail__preview {
|
||||
display: block;
|
||||
margin-top: 0.05rem;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
color: color.$ink-faint;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rail__empty {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 1.05rem;
|
||||
color: rgba(255, 238, 210, 0.35);
|
||||
padding: 0.5rem 0.75rem;
|
||||
writing-mode: vertical-rl;
|
||||
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. */
|
||||
.rail__more {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
margin-left: 0;
|
||||
padding: 0.3rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 238, 210, 0.55);
|
||||
border-radius: var.$block-radius;
|
||||
|
||||
&:hover {
|
||||
color: #fff8ea;
|
||||
background-color: rgba(255, 240, 214, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@use "@styles/color";
|
||||
|
||||
/* The ruled line is the unit this whole layout is built from: the rules on the
|
||||
* paper, the height of a page, the vertical position of every margin stamp and
|
||||
* of the caret are all multiples of it. It is declared as a CSS custom property
|
||||
* rather than a Sass variable because JS has to read the resolved pixel value
|
||||
* back out to paginate, and has to be able to do that after a resize. */
|
||||
$line-fallback: 2.1rem;
|
||||
|
||||
//Sizes
|
||||
$elem-spacing: 0.5rem;
|
||||
$text-spacing: 0.3em;
|
||||
$block-spacing: 1rem;
|
||||
$block-radius: 3px;
|
||||
|
||||
//The book
|
||||
$page-margin-width: 4.25rem; //the left column that carries date stamps
|
||||
$page-padding: 2.25rem;
|
||||
$spine-width: 2.5rem;
|
||||
$book-max-width: 72rem; //leaves the rail room to show its dates
|
||||
$book-radius: 4px;
|
||||
|
||||
//The bookmark rail
|
||||
$tab-width: 12.5rem;
|
||||
$tab-height: 3.1rem;
|
||||
//How far a tab is tucked under the edge of the book. Small on purpose: the date
|
||||
//and time are the whole point of a bookmark, so the tab is read in full and
|
||||
//only its bound edge disappears under the page block.
|
||||
$tab-tuck: 1.4rem;
|
||||
|
||||
//Typography
|
||||
$font-hand: "Caveat Variable", "Caveat", "Segoe Script", cursive;
|
||||
$font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
//Transitions
|
||||
$trans-quick: 160ms;
|
||||
$trans-mid: 280ms;
|
||||
$trans-slow: 600ms;
|
||||
|
||||
//Elevation
|
||||
$shadow-page: 0 1.5rem 3rem color.$shadow-deep;
|
||||
$shadow-panel: 0 0.75rem 2rem color.$shadow-deep;
|
||||
$shadow-elem: 0 1px 2px color.$shadow-soft;
|
||||
|
||||
//Breakpoints
|
||||
$mobile: 860px;
|
||||
$narrow: 1180px;
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Site Global CSS */
|
||||
@use '@styles/common';
|
||||
|
||||
/* The book */
|
||||
@use '@styles/app';
|
||||
@use '@styles/book';
|
||||
@use '@styles/rail';
|
||||
|
||||
/* Furniture */
|
||||
@use '@styles/calendar';
|
||||
@use '@styles/panel';
|
||||
|
||||
@use '@styles/mobile';
|
||||
|
||||
/* 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