refactor: various UI fixes

scarlett
NGPixel 1 month ago
parent cffa39c1e0
commit 4677a7c532
No known key found for this signature in database

@ -309,9 +309,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string',
enum: ['off', 'left', 'right']
},
showSharingMenu: {
type: 'boolean'
},
showPrintBtn: {
type: 'boolean'
},

@ -1013,8 +1013,6 @@
"admin.theme.secondaryColorHint": "The alternate color for secondary action buttons and for some other elements.",
"admin.theme.showPrintBtn": "Show Print Button",
"admin.theme.showPrintBtnHint": "Should the print button be displayed on all pages. Note that this doesn't prevent the user from printing the page using the system dialog.",
"admin.theme.showSharingMenu": "Show Sharing Menu",
"admin.theme.showSharingMenuHint": "Should the sharing menu be displayed on all pages.",
"admin.theme.sidebarColor": "Sidebar Color",
"admin.theme.sidebarColorHint": "The background color for the side navigation menu on content pages. Does not apply to the administration area.",
"admin.theme.sidebarPosition": "Sidebar Position",
@ -1361,6 +1359,7 @@
"common.actions.duplicate": "Duplicate",
"common.actions.edit": "Edit",
"common.actions.exit": "Exit",
"common.actions.exitEdit": "Exit Edit",
"common.actions.fetch": "Fetch",
"common.actions.filter": "Filter",
"common.actions.generate": "Generate",
@ -1497,6 +1496,7 @@
"common.notfound.title": "Not Found",
"common.outdatedBrowserWarning": "Your browser is outdated. Upgrade to a {modernBrowser}.",
"common.page.bookmark": "Bookmark",
"common.page.contents": "Contents",
"common.page.delete": "Delete Page",
"common.page.deleteSubtitle": "The page can be restored from the administration area.",
"common.page.deleteTitle": "Are you sure you want to delete page {title}?",
@ -1508,6 +1508,7 @@
"common.page.printFormat": "Print Format",
"common.page.private": "Private",
"common.page.published": "Published",
"common.page.ratePage": "Rate this page",
"common.page.returnNormalView": "Return to Normal View",
"common.page.share": "Share",
"common.page.tags": "Tags",
@ -1738,6 +1739,7 @@
"editor.reasonForChange.reasonMissing": "A reason is missing.",
"editor.reasonForChange.required": "You must provide a reason for this change. Enter a small description of what changed.",
"editor.reasonForChange.title": "Reason For Change",
"editor.renderFailed": "The preview could not be rendered. The last successful render is kept.",
"editor.renderPreview": "Render Preview",
"editor.save.createSuccess": "Page created successfully.",
"editor.save.error": "An error occurred while creating the page",

@ -135,7 +135,6 @@ class Sites {
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: 'roboto',
contentFont: 'roboto'
@ -360,7 +359,6 @@ class Sites {
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: 'roboto',
contentFont: 'roboto'

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
242 icons.
240 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -26,7 +26,6 @@ export const BUNDLED_ICONS = {
"la:broadcast-tower": {"body":"<path fill=\"currentColor\" d=\"M7.188 4.188c-4.297 4.183-4.282 11.125 0 15.406l1.406-1.407c-3.52-3.519-3.504-9.148 0-12.562zm17.625.093L23.405 5.72c3.524 3.523 3.524 9.039 0 12.562l1.407 1.438a10.897 10.897 0 0 0 0-15.438zM9.905 7.188c-2.586 2.585-2.586 6.82 0 9.406l1.406-1.407a4.68 4.68 0 0 1 0-6.593zm12.188.093L20.687 8.72a4.64 4.64 0 0 1 0 6.562l1.407 1.438c2.586-2.586 2.586-6.852 0-9.438zM16 10a2 2 0 0 0-2 2c0 .625.3 1.164.75 1.531L10.312 26H9v2h4v-2h-.594L16 15.969L19.594 26H19v2h4v-2h-1.313L17.25 13.531c.45-.367.75-.906.75-1.531a2 2 0 0 0-2-2\"/>","width":32,"height":32},
"la:broom": {"body":"<path fill=\"currentColor\" d=\"m28.281 2.281l-10 10L17 11v-.031l-.031-.031c-.64-.57-1.477-.844-2.282-.844s-1.582.3-2.187.906l-.156.125l-.5.5l-.344.281L2.375 19l-.875.719L12.281 30.5l.719-.875l7.063-9.063l.03.032l1-1h.032l.031-.032c1.14-1.285 1.149-3.257-.062-4.468l-1.375-1.375l10-10zm-13.593 9.813a1.4 1.4 0 0 1 .906.312c.011.008.02.024.031.031l4.063 4.063c.375.375.41 1.172 0 1.688c-.016.019-.016.042-.032.062l-.312.281l-5.782-5.781l.344-.344c.192-.191.473-.304.781-.312zM12.03 14.03l5.938 5.938l-5.875 7.5l-1.438-1.438l2.156-2.25l-1.437-1.375l-2.125 2.219l-1.313-1.313l3.875-3.906L10.406 18L6.5 21.875l-1.969-1.969z\"/>","width":32,"height":32},
"la:calendar": {"body":"<path fill=\"currentColor\" d=\"M9 4v1H5v22h22V5h-4V4h-2v1H11V4zM7 7h2v1h2V7h10v1h2V7h2v2H7zm0 4h18v14H7zm6 2v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 17v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2zM9 21v2h2v-2zm4 0v2h2v-2zm4 0v2h2v-2z\"/>","width":32,"height":32},
"la:caret-right": {"body":"<path fill=\"currentColor\" d=\"M12 4.594v22.812l1.719-1.687l9-9l.687-.719l-.687-.719l-9-9zm2 4.843L20.563 16L14 22.563z\"/>","width":32,"height":32},
"la:caret-square-right": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm7.219 2.281L12.78 10.72L18.062 16l-5.28 5.281l1.437 1.438l6-6l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:chalkboard": {"body":"<path fill=\"currentColor\" d=\"M5 7v16H3v2h26v-2h-2V7zm2 2h18v14H7zm14.281 3.281L17 16.562l-3.281-3.28l-.719-.688l-.719.687l-3 3l1.438 1.438L13 15.437l3.281 3.282l.719.687l.719-.687l5-5zM20 20l-1 1l1 1h4v-2z\"/>","width":32,"height":32},
"la:chart-area": {"body":"<path fill=\"currentColor\" d=\"m28 4.063l-1.625 1.25l-4.625 3.625L16.156 8l-.375-.063l-.344.22l-5.687 3.78l-4.563-.906L4 10.781V28h24zm-2 4.093v5.375l-4.219 3.344l-5.468-1.813l-.47-.156l-.405.25l-5.563 3.719L6 17.312V13.22l3.813.75l.406.094l.344-.22l5.656-3.78l5.625.937l.437.063l.344-.282zm0 7.938V26H6v-6.5l3.625 1.438l.5.187l.438-.281l5.624-3.75l5.5 1.843l.5.188l.438-.344z\"/>","width":32,"height":32},
@ -155,7 +154,6 @@ export const BUNDLED_ICONS = {
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},
"mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24},
"mdi:checkbox-blank-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5z\"/>","width":24,"height":24},
"mdi:checkbox-marked": {"body":"<path fill=\"currentColor\" d=\"m10 17l-5-5l1.41-1.42L10 14.17l7.59-7.59L19 8m0-5H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2\"/>","width":24,"height":24},
"mdi:checkbox-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5zm-9 12l-4-4l1.41-1.42L10 14.17l6.59-6.59L18 9\"/>","width":24,"height":24},
"mdi:chevron-down": {"body":"<path fill=\"currentColor\" d=\"M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z\"/>","width":24,"height":24},
"mdi:chevron-left": {"body":"<path fill=\"currentColor\" d=\"M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z\"/>","width":24,"height":24},

@ -487,8 +487,28 @@ async function toggleMarkup({ start, end }) {
}
function processContent(newContent) {
/*
A render that throws must not become a render that is empty.
`pageSave` sends whatever is in the store, and the server replaces the stored HTML with it -- so
patching a failed render in blanks the published page, and patching nothing keeps the last good
one. Loud rather than silent, because the preview is then showing something other than the source.
*/
let html
try {
html = md.render(newContent)
} catch (err) {
console.error(err)
notify({
type: 'negative',
message: t('editor.renderFailed'),
caption: err.message
})
return
}
pageStore.$patch({
render: md.render(newContent)
render: html
})
nextTick(() => {
for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) {
@ -655,7 +675,10 @@ onMounted(async () => {
lastChangeTimestamp: Temporal.Now.instant()
})
pageStore.$patch({
content: editor.getValue()
content: editor.getValue(),
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
// the guard in `pageSave`
contentLoaded: true
})
processContent(pageStore.content)
}, 500)

@ -708,6 +708,9 @@ function init() {
})
pageStore.$patch({
content: JSON.stringify(editor.getJSON()),
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
// the guard in `pageSave`
contentLoaded: true,
render: editor.getHTML()
})
}

@ -406,6 +406,7 @@ import {
computed,
defineAsyncComponent,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
@ -491,6 +492,7 @@ const barStyle = {
// REFS
const fileIpt = ref(null)
const searchField = ref(null)
const treeComp = ref(null)
// COMPUTED
@ -1142,9 +1144,25 @@ function delItem(item) {
}
}
/**
* Ctrl+K reaches THIS search field while the overlay is up.
*
* HeaderSearch owns the same shortcut and steps aside for an overlay (see the note there), so the two
* never both answer it. Bound and unbound with the component, which only exists while the overlay is
* open -- the listener's lifetime is the window in which it should win.
*/
function handleKeyPress(ev) {
if (ev.ctrlKey && ev.key === 'k') {
ev.preventDefault()
searchField.value?.focus()
}
}
// MOUNTED
onMounted(async () => {
window.addEventListener('keydown', handleKeyPress)
const pathParts = pageStore.path.split('/')
const parentPath = pathParts.slice(0, -1).join('/')
@ -1173,6 +1191,10 @@ onMounted(async () => {
state.currentFolderId = currentNode[0]
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyPress)
})
</script>
<style lang="scss">

@ -18,7 +18,6 @@
hide-bottom-space
:label="t(`fileman.folderTitle`)"
lazy-rules="ondemand"
autofocus
@keyup:enter="rename" />
</w-item-section>
</w-item>
@ -86,7 +85,9 @@ defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptTitle.value
})
// STORES

@ -8,7 +8,7 @@
</w-avatar>
<img v-else :src="`/_site/current/logo`" style="height: 34px" />
</w-btn>
<div v-if="siteStore.logoText" class="text-h6 min-w-0 flex-1 truncate">
<div v-if="siteStore.logoText" class="text-h6 ml-2 min-w-0 flex-1 truncate">
{{ siteStore.title }}
</div>
</w-toolbar>

@ -29,11 +29,17 @@
@focus="state.searchIsFocused = true"
@blur="checkSearchFocus" />
<!--
`mousedown.prevent` keeps the press from pulling focus out of the input: the blur would
swap the badge to its right (see below) and the resulting reflow shifts this button out
from under the pointer before it can be released, eating the click.
-->
<button
v-if="siteStore.search.length > 0"
type="button"
class="header-search-clear"
:aria-label="t('common.actions.clear')"
@mousedown.prevent
@click="clearSearch">
<w-icon name="la:times" />
</button>
@ -148,8 +154,14 @@ watch(searchPanelIsShown, (newValue) => {
// METHODS
/*
Ctrl+K focuses the field -- unless a full-screen overlay is up, in which case this header is behind
it and the shortcut belongs to whatever is in front. FileManager has a search field of its own and
claims it; the rest simply have nothing to focus, and pulling focus into a field the user cannot see
is worse than the key doing nothing.
*/
function handleKeyPress(ev) {
if (siteStore.features.search) {
if (siteStore.features.search && !siteStore.overlayIsShown) {
if (ev.ctrlKey && ev.key === 'k') {
ev.preventDefault()
searchField.value.focus()
@ -280,6 +292,8 @@ onBeforeUnmount(() => {
/* Sits inside the pill, against its fill, so it inverts with everything else */
&-kbd {
flex-shrink: 0;
/* -> pulls it clear of the pill's edge, where the two mismatched radii read as a kink */
margin-right: 2px;
padding: 2px 8px;
border: 1px solid currentColor;
border-radius: 9999px;

@ -1,11 +1,8 @@
<template>
<w-card class="icon-picker" flat style="width: 460px">
<!-- -> `primary` is a mid-tone for white; on the dark card the tabs need the lightened mix -->
<w-tabs
class="text-primary dark:text-primary-light"
v-model="state.currentTab"
no-caps
inline-label>
<!-- -> Inset from the card's edges: the strip is a segmented control with a track of its own, so
it sits ON the card rather than spanning it edge to edge -->
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
<w-tab name="icon" icon="la:icons" :label="t(`iconPicker.icons`)" />
<w-tab name="image" icon="la:image" :label="t(`iconPicker.image`)" />
</w-tabs>
@ -45,9 +42,9 @@
</div>
</div>
<div class="icon-picker-results mt-2">
<w-inner-loading :showing="state.loading">
<w-spinner color="primary" size="md" />
</w-inner-loading>
<!-- -> No spinner in the slot: WInnerLoading draws its own, and the slot is for what goes
BESIDE it (a caption). Passing one gave two stacked spinners. -->
<w-inner-loading :showing="state.loading" size="32px" />
<div
class="text-center text-caption text-grey p-6"
v-if="!state.loading && state.results.length < 1">
@ -73,7 +70,11 @@
<!-- An image file -->
<!-- ----------------------- -->
<w-tab-panel class="p-3" name="image">
<div class="text-caption text-grey">{{ t('iconPicker.imageHint') }}</div>
<!-- -> `text-grey` (#9e9e9e) is too faint to read at caption size; the app's secondary-text
pair holds up on both the light panel and the dark one -->
<div class="text-caption text-black/60 dark:text-white/70">
{{ t('iconPicker.imageHint') }}
</div>
<!--
The field holds the path alone; the `img:` that marks it as an image is shown as a fixed
prefix and added on the way out. That is how the reference has to be stored -- see WIcon --
@ -81,7 +82,7 @@
-->
<w-input
ref="iptImage"
class="mt-2"
class="mt-4"
v-model="state.image"
outlined
dense
@ -89,7 +90,9 @@
:label="t(`iconPicker.imageUrl`)"
:aria-label="t(`iconPicker.imageUrl`)"
placeholder="/_assets/icons/my-icon.svg" />
<div class="mt-2 text-caption text-grey">{{ t('iconPicker.imageSizeHint') }}</div>
<div class="mt-4 text-caption text-black/60 dark:text-white/70">
{{ t('iconPicker.imageSizeHint') }}
</div>
</w-tab-panel>
</w-tab-panels>
<w-separator />
@ -124,7 +127,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { notify } from '@/composables/notify'
import { useDark } from '@/composables/dark'
@ -196,6 +199,11 @@ const pendingValue = computed(() => {
return url ? `${IMAGE_PREFIX}${url}` : ''
})
// WATCHERS
// -> Switching tabs by hand lands in that tab's field, the same as opening on it does
watch(() => state.currentTab, focusCurrentTab)
// METHODS
/**
@ -286,6 +294,18 @@ function applyAndClose() {
closePopup()
}
/**
* Focus the field the visible tab leads with -- the search box, or the image path.
*
* Two ticks: the first renders the tab switch, and the field only exists once the panel it lives in
* is the visible one.
*/
async function focusCurrentTab() {
await nextTick()
await nextTick()
;(state.currentTab === 'image' ? iptImage : iptSearch).value?.focus()
}
// MOUNTED
onMounted(async () => {
@ -298,14 +318,9 @@ onMounted(async () => {
state.results = [props.modelValue]
}
/*
Focus whichever field the picker just opened on -- the search box, or the image path when an
`img:` value brought us to that tab. Two ticks: the first renders the tab switch above, and the
field being focused only exists after the panel it lives in is the visible one.
*/
await nextTick()
await nextTick()
;(state.currentTab === 'image' ? iptImage : iptSearch).value?.focus()
// -> Focus whichever field the picker opened on; an `img:` value above may have moved the tab,
// in which case the watcher is focusing the same field and this is a no-op
await focusCurrentTab()
await loadSets()
})

@ -39,8 +39,13 @@
</w-header>
<w-drawer class="bg-dark-6" :model-value="true" :width="295" dark>
<w-scroll-area class="nav-edit" :thumb-style="thumbStyle" :bar-style="barStyle">
<!--
The `q-list q-list--dense q-list--dark` this carried were the old framework's classes and
nothing defines them any more, which is why the rows had drifted to full height: the density
now comes from `dense` on each item, matching what NavSidebar renders.
-->
<sortable
class="q-list q-list--dense q-list--dark nav-edit-list"
class="nav-edit-list"
:list="state.items"
item-key="id"
:options="sortableOptions"
@ -60,6 +65,7 @@
<w-item
class="nav-edit-item nav-edit-item-link"
v-else-if="element.type === `link`"
dense
:class="{ 'is-active': state.selected === element.id, 'is-nested': element.isNested }"
@click="setItem(element)"
clickable>
@ -736,11 +742,37 @@ onBeforeUnmount(() => {
<style lang="scss" scoped>
@use 'sass:color';
/*
Light ink on an always-dark surface.
This drawer is dark whatever the site theme is, but the shared components' own dark treatments are
`dark:` variants -- keyed off `body.body--dark`, i.e. the APP theme. On a light-themed site their
light-mode colours therefore applied here: `WItemLabel`'s header variant resolved to black at 54%
and `WItemSection`'s side variant likewise, which on `dark-6` is invisible. WDrawer's `dark` prop
covers plain inherited text, not a component that states a colour of its own, so each one that does
is restated here at the value its dark variant would have used.
*/
.nav-edit {
height: 100%;
.handle {
cursor: grab;
color: rgba(255, 255, 255, 0.7);
}
/*
Same padding NavSidebar gives its own headings: `WItemLabel`'s uniform `p-4` made this row 52px
against the sidebar's 40px, so a heading looked considerably heavier here than the thing being
edited.
*/
.w-item-label--header {
color: rgba(255, 255, 255, 0.7);
padding-bottom: 4px;
}
/* -> A rule between nav items is content here, not trim: 15% white is too faint to aim at */
.nav-edit-item-separator .w-separator {
--w-hairline-color: rgb(255 255 255 / 0.32);
}
}

@ -160,6 +160,9 @@ watch(
&-header {
color: rgba(255, 255, 255, 0.75) !important;
/* -> WItemLabel's uniform `p-4` leaves the heading floating between its own group and the one
above it; tightening the bottom side ties it to the links it labels */
padding-bottom: 4px;
}
}
</style>

@ -356,6 +356,10 @@ function notImplemented() {
}
}
/* -> Taller than the shell only on a very short window, and then it scrolls rather than clipping */
overflow-y: auto;
scrollbar-width: none;
&-mode {
writing-mode: vertical-rl;
text-orientation: mixed;

@ -88,7 +88,7 @@
</w-btn>
<w-btn
class="ml-4"
v-if="siteStore.theme.showSharingMenu"
v-if="!pageStore.isHome"
flat
dense
icon="la:share-alt"
@ -130,6 +130,22 @@
<w-tooltip>{{ t(`editor.settings`) }}</w-tooltip>
</w-btn>
</template>
<!--
Not `v-else-if` on the block below: changes made from the page properties panel put the header
into the pending state without an editor behind it, and hiding Edit there left no way back into
the content at all. Ahead of the commit actions so those stay rightmost.
-->
<template v-if="!editorStore.isActive && userStore.can(`edit:pages`)">
<w-btn
class="acrylic-btn ml-4"
flat
icon="la:edit"
color="deep-orange-9"
:label="t(`common.actions.edit`)"
:aria-label="t(`common.actions.edit`)"
no-caps
@click="editPage" />
</template>
<template v-if="editorStore.isActive || editorStore.hasPendingChanges">
<w-btn
class="acrylic-btn ml-2"
@ -181,17 +197,6 @@
</template>
</w-btn-group>
</template>
<template v-else-if="userStore.can(`edit:pages`)">
<w-btn
class="acrylic-btn ml-4"
flat
icon="la:edit"
color="deep-orange-9"
:label="t(`common.actions.edit`)"
:aria-label="t(`common.actions.edit`)"
no-caps
@click="editPage" />
</template>
</div>
</div>
</template>

@ -5,7 +5,9 @@
<w-icon name="img:/_assets/icons/fluent-query.svg" size="sm" class="mr-2" />
<span>{{ t(`editor.reasonForChange.title`) }}</span>
</w-card-section>
<w-card-section>
<!-- -> `pb-0`: the row below pads itself and the field adds its own margin for the floating
label, so the section's own 16px on top of those left the prompt adrift from its field -->
<w-card-section class="pb-0">
<div v-if="props.required" class="text-body2">
{{ t(`editor.reasonForChange.required`) }}
</div>
@ -15,14 +17,14 @@
<w-item>
<w-item-section>
<w-input
ref="iptReason"
v-model="state.reason"
outlined
dense
:rules="reasonValidation"
hide-bottom-space
:label="t(`editor.reasonForChange.field`)"
lazy-rules="ondemand"
autofocus />
lazy-rules="ondemand" />
</w-item-section>
</w-item>
</w-form>
@ -51,7 +53,7 @@
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
// PROPS
@ -69,7 +71,9 @@ defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptReason.value
})
// I18N
@ -85,10 +89,20 @@ const state = reactive({
// REFS
const reasonForm = ref(null)
const iptReason = ref(null)
// VALIDATION RULES
const reasonValidation = [(val) => val.length > 0 || t('editor.reasonForChange.reasonMissing')]
/*
No rule at all when the reason is optional, rather than a rule the field is exempt from.
WForm validates every registered field on submit and only emits `submit` if they all pass, so an
unconditional rule made Enter on an empty field report a missing reason and swallow the submit --
even though `commit()` itself only validates when the reason is required.
*/
const reasonValidation = computed(() =>
props.required ? [(val) => val.length > 0 || t('editor.reasonForChange.reasonMissing')] : []
)
// METHODS

@ -29,7 +29,8 @@
:horizontal-thumb-style="{ height: `5px` }"
style="width: 100%; height: calc(100vh - 100px);">
<div class="p-4 text-grey-5" v-if="state.notice">{{ state.notice }}</div>
<pre class="px-4" v-else v-text="state.content"></pre>
<!-- -> `pt-4` so the first line clears the header rather than sitting against it -->
<pre class="px-4 pt-4" v-else v-text="state.content"></pre>
</w-scroll-area>
</w-page>
</w-page-container>

@ -15,38 +15,27 @@
<span class="text-caption">{{tag}}</span>
</w-chip>
</template>
<!--
Entry only: no `use-chips`, because the selection is already shown as the chips above and having
it in the field as well said the same thing twice. `create` is what lets a tag that does not
exist yet be typed in; the suggestions are filtered by WSelect itself, from what is typed.
-->
<w-select
class="mt-4"
v-if="props.edit"
outlined
v-model="pageStore.tags"
:options="state.filteredTags"
:options="state.tags"
dense
options-dense
use-input
use-chips
create
multiple
hide-selected
hide-dropdown-icon
:input-debounce="0"
new-value-mode="add-unique"
@new-value="createTag"
@filter="filterTags"
@create="createTag"
:placeholder="t(`editor.props.tagsPlaceholder`)"
:aria-label="t(`editor.props.tags`)"
:loading="state.loading">
<template v-slot:option="scope">
<w-item v-bind="scope.itemProps">
<w-item-section side>
<w-checkbox
:model-value="scope.selected"
@update:model-value="scope.toggleOption(scope.opt)"
size="sm" />
</w-item-section>
<w-item-section><w-item-label><span v-html="scope.opt" /></w-item-label></w-item-section>
</w-item>
</template>
</w-select>
:loading="state.loading" />
</div>
</template>
@ -83,8 +72,8 @@ const { t } = useI18n()
// DATA
const state = reactive({
/** Every tag on the site, as suggestions. WSelect narrows these against what is typed. */
tags: [],
filteredTags: [],
loading: false
})
@ -129,34 +118,31 @@ watch(
// METHODS
function filterTags(val, update) {
update(() => {
if (val === '') {
state.filteredTags = state.tags
} else {
const tagSearch = val.toLowerCase()
state.filteredTags = state.tags.filter((v) => v.toLowerCase().indexOf(tagSearch) >= 0)
}
})
}
/**
* Add whatever was typed, as one tag or as several.
*
* A comma or a semicolon separates tags, so a list can be pasted in one go. Each new one joins the
* suggestions too, so re-typing it offers a match rather than looking unknown.
*/
function createTag(val) {
const tags = val
.split(/[,;]+/)
.map((v) => v.trim())
.filter(Boolean)
if (tags.length === 0) {
return
}
function createTag(val, done) {
if (val) {
const currentTags = pageStore.tags.slice()
for (const tag of val
.split(/[,;]+/)
.map((v) => v.trim())
.filter((v) => v)) {
if (!state.tags.includes(tag)) {
state.tags.push(tag)
}
if (!currentTags.includes(tag)) {
currentTags.push(tag)
}
const nextSelection = pageStore.tags.slice()
for (const tag of tags) {
if (!state.tags.includes(tag)) {
state.tags.push(tag)
}
if (!nextSelection.includes(tag)) {
nextSelection.push(tag)
}
done('')
pageStore.tags = currentTags
}
pageStore.tags = nextSelection
}
function removeTag(tag) {

@ -0,0 +1,339 @@
<template>
<nav class="page-toc" aria-label="Table of contents">
<ul class="page-toc-list">
<li
v-for="item of visibleItems"
:key="item.key"
class="page-toc-item"
:class="[
`page-toc-item--d${Math.min(item.depth, 2)}`,
{ 'page-toc-item--active': item.key === selected }
]"
:style="{ '--page-toc-depth': item.depth }">
<!--
A real `href` so the section can be middle-clicked or copied, with the click handled here
instead: scrolling it into view keeps the reader where the wiki put them, rather than
handing the URL a fragment the router would then try to resolve.
-->
<a class="page-toc-link" :href="item.key" @click="onClick($event, item)">{{
item.label
}}</a>
</li>
</ul>
</nav>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { flattenToc } from '@/helpers/toc'
/**
* The page contents: a list of links to the headings in the render, marking the one being read.
*
* Depth is expressed by indentation off a single rail plus a type ramp heavier and darker at the
* top level, lighter and smaller further in. There is no disclosure control: which levels the
* contents cover is the page's own setting (`tocDepth`, as `minDepth`/`maxDepth` here), not something
* to fiddle with per visit, and a column of carets both wasted the width and read as a file tree.
*/
const props = defineProps({
/** The contents tree: `{ key, label, children }`, where `key` is the heading's `#anchor`. */
nodes: {
type: Array,
required: true
},
/**
* The shallowest level to show, counting from 1 so `2` skips the first level, and its
* subheadings become the top tier of the list. The page properties panel presents the pair as
* `H{min} → H{max}`.
*/
minDepth: {
type: Number,
default: 1
},
/** The deepest level to show, counting from 1. Anything below it is left out entirely. */
maxDepth: {
type: Number,
default: 2
},
/** Key of the heading being read. Owned by the caller; this component keeps it up to date. */
selected: {
type: String,
default: null
}
})
const emit = defineEmits(['update:selected'])
/*
Where the page counts as being "at" a heading: the first heading whose top has passed this line,
measured down from the top of whatever box the article scrolls in. Deliberately below that edge, so
a heading becomes current as it settles into reading position rather than the instant its first
pixel appears.
*/
const SPY_LINE = 120
/*
How long the spy stands down after a click. A smooth scroll passes over every heading in between,
and letting the marker run down the list behind it looks like a fault; the click already said which
heading is meant. Long enough for a scroll of any length to settle.
*/
const CLICK_SETTLE_MS = 1200
let spyFrame = null
let spySuspendedUntil = 0
// COMPUTED
/**
* The tree flattened to one list, each row carrying its own depth.
*
* One list rather than a component per level: the rail and the active marker are then a single
* positioning context, so a row at any depth marks the same 1px line.
*/
const visibleItems = computed(() =>
flattenToc(props.nodes, { minDepth: props.minDepth, maxDepth: props.maxDepth })
)
// METHODS
/** The heading element a row points at, or null while the render is still catching up. */
function headingFor(key) {
// -> `key` is the anchor `#slug`; the bare id is what `getElementById` wants, and it sidesteps
// having to escape a slug that is not a valid CSS selector
return document.getElementById(key.replace(/^#/, ''))
}
function onClick(ev, item) {
const heading = headingFor(item.key)
if (!heading) {
// -> Nothing to scroll to; let the browser do whatever it can with the href
return
}
ev.preventDefault()
emit('update:selected', item.key)
spySuspendedUntil = performance.now() + CLICK_SETTLE_MS
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
heading.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start' })
}
/**
* Where the box the article scrolls in starts, in viewport coordinates.
*
* The shell is the viewport and the article scrolls in a column inside it, so a heading at the top of
* its own scrollport is still ~200px down the window, under the header and the page title. Measuring
* the reading line from the window instead put the spy a heading behind wherever the reader was.
*/
function scrollportTop(heading) {
for (let el = heading.parentElement; el; el = el.parentElement) {
if (['auto', 'scroll'].includes(getComputedStyle(el).overflowY)) {
return el.getBoundingClientRect().top
}
}
return 0
}
/**
* Mark whichever heading the reader has reached.
*
* Positions are read fresh each time rather than cached: the render is replaced wholesale while
* editing, and images settling in shift every heading below them.
*/
function syncSpy() {
if (performance.now() < spySuspendedUntil || visibleItems.value.length === 0) {
return
}
let current = null
let line = null
for (const item of visibleItems.value) {
const heading = headingFor(item.key)
if (!heading) {
continue
}
line ??= scrollportTop(heading) + SPY_LINE
if (heading.getBoundingClientRect().top <= line) {
current = item.key
}
}
// -> Above the first heading, the first section is still the one being read
const next = current ?? visibleItems.value[0].key
if (next !== props.selected) {
emit('update:selected', next)
}
}
/** Scroll fires far more often than the marker can move; one read per frame is enough. */
function queueSpy() {
if (spyFrame !== null) {
return
}
spyFrame = requestAnimationFrame(() => {
spyFrame = null
syncSpy()
})
}
// WATCHERS
// -> A new render means new heading positions, and possibly a different set of them
watch(() => props.nodes, queueSpy)
// MOUNTED
onMounted(() => {
/*
`capture` because scroll events do not bubble: the page scrolls the document today, but the
content sits in a scroll container that takes over at shorter viewports, and capturing on the
window catches whichever one moved.
*/
window.addEventListener('scroll', queueSpy, { capture: true, passive: true })
window.addEventListener('resize', queueSpy, { passive: true })
queueSpy()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', queueSpy, { capture: true })
window.removeEventListener('resize', queueSpy)
if (spyFrame !== null) {
cancelAnimationFrame(spyFrame)
}
})
</script>
<style lang="scss">
/*
The contents list.
Everything hangs off one vertical rail at the left: depth is indentation from it, and the heading
being read marks it. Colours come from CSS custom properties rather than the SCSS palette so that
`--color-primary` follows a re-themed site, which a compiled `$primary` could not.
*/
.page-toc {
--page-toc-indent: 14px;
/* Translucent, not a palette grey: the rail sits on the sidebar's own fill in both themes */
--page-toc-rail: rgba(0, 0, 0, 0.1);
--page-toc-ink-strong: #{$grey-9};
--page-toc-ink: #{$grey-7};
--page-toc-ink-soft: #{$grey-6};
--page-toc-ink-hover: #{$grey-10};
--page-toc-hover-surface: rgba(0, 0, 0, 0.04);
line-height: 1.4;
@at-root .body--dark & {
--page-toc-rail: rgba(255, 255, 255, 0.12);
--page-toc-ink-strong: rgba(255, 255, 255, 0.87);
--page-toc-ink: rgba(255, 255, 255, 0.6);
--page-toc-ink-soft: rgba(255, 255, 255, 0.45);
--page-toc-ink-hover: #fff;
--page-toc-hover-surface: rgba(255, 255, 255, 0.06);
}
&-list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
/* The rail. Inset top and bottom so it stops level with the first and last label. */
&::before {
content: '';
position: absolute;
top: 3px;
bottom: 3px;
left: 0;
width: 1px;
background-color: var(--page-toc-rail);
}
}
&-item {
position: relative;
/* Depth is carried as a custom property by the template, so one rule indents every level */
padding-left: calc(var(--page-toc-depth) * var(--page-toc-indent));
}
/*
The active marker, drawn ON the rail rather than beside it: `left: 0` is the item's own border
box, which starts at the rail whatever the indentation, so every depth marks the same line.
*/
&-item--active::before {
content: '';
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 2px;
border-radius: 1px;
background-color: var(--color-primary);
}
&-link {
display: block;
/* 9px of gutter, not a caret column: the rail is the only thing to the left of a label */
padding: 3px 8px 3px 9px;
border-radius: 4px;
color: inherit;
font-size: inherit;
font-weight: inherit;
text-decoration: none;
/* Long headings wrap rather than being cut off; the sidebar is only 300px wide */
overflow-wrap: break-word;
transition:
color 0.2s var(--ease-standard),
background-color 0.2s var(--ease-standard);
&:hover {
color: var(--page-toc-ink-hover);
background-color: var(--page-toc-hover-surface);
}
}
/*
The depth ramp. Each level steps down in weight, size and contrast, so nesting is legible from
the type alone -- indentation on its own left every level looking like the same kind of thing.
*/
&-item--d0 {
color: var(--page-toc-ink-strong);
font-size: 0.8125rem;
font-weight: 500;
}
/* Air above each top-level entry, which is what separates one section's block from the next */
&-item--d0 + &-item--d0,
&-item--d1 + &-item--d0,
&-item--d2 + &-item--d0 {
margin-top: 7px;
}
&-item--d1 {
color: var(--page-toc-ink);
font-size: 0.78125rem;
font-weight: 400;
}
&-item--d2 {
color: var(--page-toc-ink-soft);
font-size: 0.75rem;
font-weight: 400;
}
/* Active beats the ramp at every depth, and keeps that depth's own weight */
&-item--active {
color: var(--color-primary);
@at-root .body--dark & {
color: var(--color-primary-light);
}
}
@media (prefers-reduced-motion: reduce) {
&-link {
transition-duration: 0.01ms;
}
}
}
</style>

@ -45,8 +45,18 @@ defineProps({
min-height: 0;
}
/*
An app shell: the layout is exactly the viewport, and what scrolls is the page cell inside it.
`min-height: 100vh` here instead meant the whole document scrolled, which pushed the header, the
drawers and the footer off the top of the screen and left everything anchored to the bottom of a
column -- the sidebar's own action bar, the page's action rail -- sitting at the end of the article
rather than at the bottom of the window. Those things belong to the shell, so the shell has to be
what stays still.
*/
.w-layout--page {
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.w-layout--container {
@ -55,16 +65,20 @@ defineProps({
}
/*
A container layout is bounded by its parent (a dialog), so anything taller than that box has to
scroll SOMEWHERE. The layout engine this replaces wrapped its whole tree in a scrolling div; here
the page cell scrolls instead, which keeps the header and the drawer in place rather than sliding
them away -- the behaviour an overlay with a sidebar wants.
Either way the layout is bounded -- by the viewport, or by the dialog holding it -- so anything
taller than that box has to scroll SOMEWHERE. The layout engine this replaces wrapped its whole
tree in a scrolling div; here the page cell scrolls instead, which keeps the header and the drawers
in place rather than sliding them away.
`min-height: 0` is what makes it work at all: a grid item's automatic minimum size is its content,
so without this the `1fr` row grows to fit and there is nothing left to scroll. Reached with
`:deep()` because the page cell is a child COMPONENT and this rule has to cross that boundary.
A page that manages its own scrolling -- the page view scrolls its article column, so that the
contents sidebar beside it stays put -- simply never overflows this box, and no second scrollbar
appears.
*/
.w-layout--container :deep(> .w-page-container) {
.w-layout :deep(> .w-page-container) {
min-height: 0;
overflow: auto;
}

@ -17,9 +17,9 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
* Floating "back to top" affordance that appears once the page has been scrolled past
* `scrollOffset`, and smooth-scrolls to the top when clicked.
*
* Simplification: only the bottom-right placement is offered, which is the only one the app used.
* Scrolling uses the platform's own smooth behaviour rather than the hand-rolled easing the
* previous component shipped, and honours `prefers-reduced-motion` for free.
* Sits in the bottom-right corner unless `anchorX` puts it somewhere else horizontally. Scrolling
* uses the platform's own smooth behaviour rather than the hand-rolled easing the previous component
* shipped, and honours `prefers-reduced-motion` for free.
*/
const props = defineProps({
/** Show once the window has scrolled this many pixels. */
@ -27,35 +27,65 @@ const props = defineProps({
type: Number,
default: 1000
},
/** `[x, y]` distance from the viewport corner, in px. */
/** `[x, y]` distance from the viewport corner, in px. `x` is unused when `anchorX` is set. */
offset: {
type: Array,
default: () => [18, 18]
},
/**
* Any CSS length, which becomes the x of the button's CENTRE measured from the left of the
* viewport so it can straddle an edge rather than clear it. Null keeps it in the corner.
*/
anchorX: {
type: String,
default: null
},
/**
* Selector for the element that scrolls, when it is not the window.
*
* The shell is the viewport, so a page view scrolls its own article column rather than the
* document; this button lives in the shell, outside that column, and so cannot find it by looking
* upwards. Resolved on each use rather than held, because the element belongs to the routed page
* and is replaced whenever that changes. Falls back to the window when there is no match.
*/
target: {
type: String,
default: null
}
})
const visible = ref(false)
const anchorStyle = computed(() => ({
right: `${props.offset[0]}px`,
bottom: `${props.offset[1]}px`
}))
const anchorStyle = computed(() => {
const bottom = `${props.offset[1]}px`
// -> `translateX(-50%)` is what makes `anchorX` a centre rather than a left edge
return props.anchorX
? { bottom, left: props.anchorX, transform: 'translateX(-50%)' }
: { bottom, right: `${props.offset[0]}px` }
})
/** The scrolling element, or null when it is the window. */
function scroller() {
return props.target ? document.querySelector(props.target) : null
}
function onScroll() {
visible.value = window.scrollY > props.scrollOffset
const el = scroller()
visible.value = (el ? el.scrollTop : window.scrollY) > props.scrollOffset
}
function scrollToTop() {
// -> `smooth` is ignored when the user has asked for reduced motion, which is the behaviour we want
window.scrollTo({ top: 0, behavior: 'smooth' })
;(scroller() ?? window).scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
// -> `capture`, because a scroll event on an element does not bubble to the window
window.addEventListener('scroll', onScroll, { capture: true, passive: true })
onScroll()
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll, { capture: true }))
</script>
<style scoped>

@ -86,6 +86,11 @@
@remove="deselect(v)" />
</span>
<!--
`outline-none` because the FIELD is what shows focus, with its ring: the user agent's own
outline drew a second, black one inside the rounded frame. Placeholder colour matched to
WInput's, which this had been leaving to the browser as well.
-->
<input
v-if="useInput"
:id="selectId"
@ -103,7 +108,7 @@
:disabled="isDisabled"
:readonly="readonly"
:placeholder="useChips && hasSelection ? '' : placeholder"
class="w-unstyled min-w-8 flex-1 bg-transparent pt-0.5"
class="w-unstyled min-w-8 flex-1 bg-transparent pt-0.5 outline-none placeholder:text-black/40 dark:placeholder:text-white/40"
@focus="readonly || open(0)"
@keydown="onKeydown" />
<span
@ -150,14 +155,19 @@
]"
@click.stop="select(opt.value)"
@mousemove="activeIndex = idx">
<w-icon
v-if="multiple"
:name="isSelected(opt.value) ? 'mdi:checkbox-marked' : 'mdi:checkbox-blank-outline'"
class="shrink-0" />
<!--
A check, not a checkbox. The icon takes the row's own font size unless told otherwise,
which made a 14px square that read as a rendering fault rather than a control -- and the
row already announces its state by colouring itself. The column is held open when
nothing is drawn, so labels line up whatever is selected.
-->
<span v-if="multiple" class="flex w-5 shrink-0 justify-center">
<w-icon v-if="isSelected(opt.value)" name="mdi:check" size="20px" />
</span>
<span class="min-w-0 flex-1">
<!--
`option` customises the row's content only. Selection mechanics (the checkbox and
the click handling) stay with the component, so a caller cannot accidentally wire a
`option` customises the row's content only. Selection mechanics (the check and the
click handling) stay with the component, so a caller cannot accidentally wire a
nested control that toggles twice -- which is what the markup this replaces had to
guard against by hand.
-->
@ -326,6 +336,18 @@ const props = defineProps({
type: Boolean,
default: false
},
/**
* Let what has been typed become a value of its own.
*
* With `useInput`, Enter on a query that matches no highlighted option emits `create` with the
* trimmed text instead of closing the popup. The caller decides what that means adding it to
* `options` and to the selection, typically because only the caller knows whether the thing is
* allowed to exist.
*/
create: {
type: Boolean,
default: false
},
/** Show the selection as removable chips instead of comma-joined text. */
useChips: {
type: Boolean,
@ -355,7 +377,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'create'])
const isOpen = ref(false)
/** Pointer-over, for the ring: the ring is an inline style, so CSS `:hover` cannot reach it. */
@ -414,6 +436,16 @@ const filteredOptions = computed(() => {
return normalizedOptions.value.filter((o) => o.label.toLowerCase().includes(needle))
})
/*
Keep the keyboard cursor inside the list it is pointing at. Typing narrows the options under it, and
a cursor left past the end made Enter read an option that was no longer there.
*/
watch(filteredOptions, (options) => {
if (activeIndex.value >= options.length) {
activeIndex.value = options.length > 0 ? 0 : -1
}
})
const selectedValues = computed(() => {
if (props.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue : []
@ -578,20 +610,33 @@ function onKeydown(ev) {
}
return
case 'Enter':
case ' ':
case ' ': {
/*
Both would otherwise reach the <button> as a click and toggle the popup shut, discarding
the cursor. When open they commit instead; when closed the default click opens as usual.
*/
if (isOpen.value) {
ev.preventDefault()
if (activeIndex.value >= 0) {
select(filteredOptions.value[activeIndex.value].value)
} else {
isOpen.value = false
}
// -> Space is a character to a field with a text input, not a commit key: it belongs to the query
if (ev.key === ' ' && props.useInput) {
return
}
if (!isOpen.value) {
return
}
ev.preventDefault()
if (activeIndex.value >= 0) {
select(filteredOptions.value[activeIndex.value].value)
return
}
// -> Nothing to commit, so what was typed is the value -- see the `create` prop
const typed = query.value.trim()
if (props.create && typed) {
emit('create', typed)
query.value = ''
return
}
isOpen.value = false
return
}
case 'Escape':
if (isOpen.value) {
ev.preventDefault()

@ -5,22 +5,29 @@
:aria-selected="String(isActive)"
:tabindex="isActive ? 0 : -1"
:disabled="disable || disabled"
class="w-tab w-unstyled relative flex min-h-12 shrink-0 cursor-pointer items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
class="w-tab w-unstyled flex min-h-10 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md px-4 py-1.5 text-sm transition-[background-color,box-shadow,color] duration-200 ease-[var(--ease-standard)]"
:class="[
tabs?.inlineLabel.value ? 'flex-row' : 'flex-col',
tabs?.noCaps.value ? 'normal-case' : 'uppercase',
isActive ? 'w-tab--active' : 'opacity-70 hover:opacity-100',
/*
Active is a raised pill: a light fill lifted off the track by a shadow, with the label at
full strength and half a step bolder. Inactive carries its state in the ink alone -- a
faded-out pill would still read as a pill, which is what makes the active one legible.
*/
isActive
? 'w-tab--active bg-white font-semibold text-black shadow-sm dark:bg-dark-2 dark:text-white'
: 'font-medium text-black/45 hover:text-black/70 dark:text-white/45 dark:hover:text-white/70',
disable || disabled ? 'pointer-events-none opacity-40' : ''
]"
@click="tabs?.select(name)">
<w-icon v-if="icon" :name="icon" />
<!--
Sized explicitly: WIcon with no `size` inherits the font size, so a tab icon came out at the
label's 14px. A tab icon is 24px, label beside it or above it, which is the metric the rest of
the app's icon rows are drawn to.
-->
<w-icon v-if="icon" :name="icon" size="sm" />
<span v-if="label">{{ label }}</span>
<slot />
<!-- The active marker, drawn under the tab rather than as a strip that slides between them -->
<span
v-if="isActive"
class="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-current"
aria-hidden="true" />
</button>
</template>

@ -1,7 +1,7 @@
<template>
<div
ref="listEl"
class="w-tabs flex flex-nowrap items-stretch overflow-x-auto"
class="w-tabs flex flex-nowrap items-stretch gap-1 overflow-x-auto rounded-lg bg-grey-2 p-1 dark:bg-dark-5"
role="tablist"
@keydown="onKeydown">
<slot />
@ -12,11 +12,16 @@
import { computed, provide, ref } from 'vue'
/**
* A strip of tabs. `WTab` children register through it; the panels are either `WTabPanels` or,
* where a caller prefers, plain `v-if` on the same model.
* A strip of tabs, drawn as a segmented control: a tinted track holding the tabs, with the active
* one raised out of it as a light pill (see `WTab`). `WTab` children register through it; the panels
* are either `WTabPanels` or, where a caller prefers, plain `v-if` on the same model.
*
* Simplification: no scroll arrows, no overflow menu, no animated indicator travel. The two tab
* strips in this app have two tabs each; the strip simply scrolls if it ever cannot fit.
* The track fill is a utility rather than a rule in this file's stylesheet, so a caller that puts
* the strip on a surface of its own -- `PageDataDialog` bands it with `alt-card` -- still overrides
* it. An SFC stylesheet is emitted unlayered and would outrank any such class.
*
* Simplification: no scroll arrows, no overflow menu. The two tab strips in this app have two tabs
* each; the strip simply scrolls if it ever cannot fit.
*/
const props = defineProps({
modelValue: {

@ -0,0 +1,40 @@
/**
* Flatten a page's contents tree to the rows a contents list draws.
*
* Shared rather than living inside `PageToc`, because which rows survive the depth settings is also
* what decides whether the sidebar has a contents section at all the heading above the list and the
* separators between the sidebar's sections are the caller's markup, so the caller has to be able to
* ask the same question and get the same answer.
*
* `minDepth` and `maxDepth` are levels counting from 1, matching how the page properties panel labels
* them (`H{min} → H{max}`). A row's `depth` is rebased on `minDepth`, so skipped levels give up their
* indentation with them and the list opens at its own top tier; skipped headings are still walked
* through, since it is their subheadings that are being asked for.
*
* @param {Array<{ key: string, label: string, children?: Array }>} nodes The contents tree.
* @param {object} [opts]
* @param {number} [opts.minDepth] Shallowest level to include, from 1.
* @param {number} [opts.maxDepth] Deepest level to include, from 1.
* @returns {Array<{ key: string, label: string, depth: number }>} Rows, in document order.
*/
export function flattenToc(nodes, { minDepth = 1, maxDepth = 2 } = {}) {
const rows = []
const skipped = Math.max(minDepth - 1, 0)
const walk = (level, depth) => {
if (depth >= maxDepth) {
return
}
for (const node of level) {
if (depth >= skipped) {
rows.push({ key: node.key, label: node.label, depth: depth - skipped })
}
if (node.children?.length) {
walk(node.children, depth + 1)
}
}
}
walk(nodes ?? [], 0)
return rows
}

@ -562,6 +562,14 @@ onMounted(async () => {
background-color: $dark-5;
}
// -> Nav rows are a 24px icon and its label, so the avatar column's 56px track centres the icon
// and leaves the pair reading as two columns rather than one item. Sizing the column to the
// icon leaves the section's own 16px as the whole gap. Needs the extra `.w-list` to outrank
// WItemSection's scoped rule, which matches on specificity alone.
.w-list .w-item-section--avatar {
min-width: auto;
}
// -> The section headings between nav groups; the double shadow is the divider above them
.w-item-label--header {
box-shadow:

@ -7,7 +7,7 @@
class="bg-sidebar"
:model-value="isSidebarShown"
:show-if-above="siteStore.theme.sidebarPosition !== `off`"
:width="isSidebarMini ? 56 : 255"
:width="sidebarWidth"
:side="siteStore.theme.sidebarPosition === `right` ? `right` : `left`">
<div v-if="isSidebarMini" class="sidebar-mini flex flex-col items-stretch">
<w-btn
@ -100,7 +100,12 @@
</w-drawer>
<w-page-container>
<router-view />
<w-page-scroller :scroll-offset="150" :offset="[15, 15]">
<!-- -> `.page-container-scrl` is the page view's article column, which is what scrolls -->
<w-page-scroller
:scroll-offset="150"
:offset="[15, 15]"
:anchor-x="scrollerAnchorX"
target=".page-container-scrl">
<w-btn icon="la:arrow-up" color="primary" round size="md" />
</w-page-scroller>
</w-page-container>
@ -117,6 +122,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { useI18n } from 'vue-i18n'
import { useCommonStore } from '@/stores/common'
@ -178,6 +184,36 @@ const isSidebarMini = computed(() => {
return ['hide', 'hideExact'].includes(pageStore.navigationMode) || !pageStore.navigationId
})
/** Sidebar widths, in px: the full nav, and the icon rail it collapses to. */
const SIDEBAR_WIDTH = 255
const SIDEBAR_WIDTH_MINI = 56
const sidebarWidth = computed(() => (isSidebarMini.value ? SIDEBAR_WIDTH_MINI : SIDEBAR_WIDTH))
/**
* Where the drawer stops overlaying the page and takes its own column. Matches `WDrawer`'s own
* breakpoint below it there is no seam to straddle, because the sidebar is not beside anything.
*/
const isWideViewport = useMinWidth(1024)
/*
The scroll-to-top button straddles the seam between the sidebar and the content, half over each, so
its centre is the sidebar's inner edge — which is the sidebar's width on the left, or the same
distance in from the right when the site puts its sidebar there.
Null puts it back in the corner, for every case where there is no seam: a narrow viewport (the
drawer overlays the page), a site with no sidebar, and the editor, which closes the sidebar to take
the full width.
*/
const scrollerAnchorX = computed(() => {
if (!isWideViewport.value || !isSidebarShown.value || siteStore.theme.sidebarPosition === 'off') {
return null
}
return siteStore.theme.sidebarPosition === 'right'
? `calc(100% - ${sidebarWidth.value}px)`
: `${sidebarWidth.value}px`
})
// -> Saving from this menu needs manage:navigation, so offering it to anyone else only produces a
// permission error once they press Save
const canEditNav = computed(() => {
@ -211,8 +247,14 @@ function notImplemented() {
height: 100%;
}
/*
No background of its own, and nothing sticky: the drawer is the height of the shell and the nav list
above scrolls inside itself, so this bar sits at the bottom of the window by being last in the
column. WBar's own translucent tint is what colours it -- the `background-color` that used to be
declared here never applied, its scoped rule outranking a single class.
*/
.sidebar-footerbtns {
background-color: rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
body.body--dark {

@ -199,19 +199,6 @@
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="share" />
<w-item-section>
<w-item-label>{{ t(`admin.theme.showSharingMenu`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.theme.showSharingMenuHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.showSharingMenu"
:aria-label="t(`admin.theme.showSharingMenu`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="print" />
<w-item-section>
@ -390,7 +377,6 @@ function defaultConfig() {
contentWidth: 'full',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true,
baseFont: 'roboto',
contentFont: 'roboto'
@ -743,7 +729,6 @@ async function save() {
contentWidth: state.config.contentWidth,
sidebarPosition: state.config.sidebarPosition,
tocPosition: state.config.tocPosition,
showSharingMenu: state.config.showSharingMenu,
showPrintBtn: state.config.showPrintBtn,
baseFont: state.config.baseFont,
contentFont: state.config.contentFont

@ -1,5 +1,10 @@
<template>
<w-page class="flex flex-col">
<!--
`h-full min-h-0`: the shell hands this page a definite height, and the page has to CLAIM it for
the article column below to scroll on its own. Left to grow, the whole page would scroll inside
the shell instead and take the sidebars with it.
-->
<w-page class="flex flex-col h-full min-h-0">
<div class="page-breadcrumbs py-2 px-4 flex flex-wrap" v-if="!editorStore.isActive">
<div class="min-w-0 flex-1">
<w-breadcrumbs
@ -20,7 +25,8 @@
</div>
</div>
<page-header />
<div class="page-container flex flex-nowrap items-stretch" style="flex: 1 1 100%">
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%">
<div
class="min-w-0 flex-1"
:style="siteStore.theme.tocPosition === `left` ? `order: 2;` : `order: 1;`">
@ -89,56 +95,63 @@
class="page-sidebar"
v-if="showSidebar"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`">
<template v-if="pageStore.showToc">
<template v-if="showToc">
<!-- TOC -->
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:stream" color="grey" />
<div class="text-caption text-grey-7">Contents</div>
<!-- -> Its own string, not `common.page.toc`: this heading labels a column beside the
article and reads better short, where "Table of Contents" is the full name of the
thing and belongs where there is room for it -->
<div class="text-caption text-grey-7">{{ t('common.page.contents') }}</div>
</div>
<div class="px-4 pb-2">
<w-tree
class="page-toc"
<page-toc
:nodes="pageStore.toc"
icon="la:caret-right"
node-key="key"
dense
v-model:expanded="state.tocExpanded"
:min-depth="pageStore.tocDepth.min"
:max-depth="pageStore.tocDepth.max"
v-model:selected="state.tocSelected" />
</div>
</template>
<!-- Tags -->
<template v-if="pageStore.showTags">
<w-separator v-if="pageStore.showToc" />
<w-separator v-if="showToc" />
<div
class="p-4"
@mouseover="state.showTagsEditBtn = true"
@mouseleave="state.showTagsEditBtn = false">
<div class="flex items-center">
<w-icon class="mr-2" name="la:tags" color="grey" />
<div class="text-caption text-grey-7">Tags</div>
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
<w-space />
<transition name="fade">
<w-btn
v-show="state.showTagsEditBtn"
size="sm"
padding="none xs"
icon="la:pen"
color="deep-orange-9"
flat
label="Edit"
no-caps
@click="state.tagEditMode = !state.tagEditMode" />
</transition>
<!--
Always rendered, hidden with `visibility` rather than removed: `display: none` took the
row's height with it, so the heading jumped 6px the moment the pointer arrived.
`visibility` also keeps it out of the tab order and out of hit-testing while hidden,
which `opacity: 0` on its own would not.
It stays put while editing, because that is when it is the way back out.
-->
<w-btn
class="tags-edit-btn"
:class="{ 'is-hidden': !state.tagEditMode && !state.showTagsEditBtn }"
size="sm"
padding="none xs"
:icon="state.tagEditMode ? `la:check` : `la:pen`"
color="deep-orange-9"
flat
:label="state.tagEditMode ? t('common.actions.exitEdit') : t('common.actions.edit')"
no-caps
@click="state.tagEditMode = !state.tagEditMode" />
</div>
<page-tags class="mt-2" :edit="state.tagEditMode" />
</div>
</template>
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings">
<w-separator v-if="pageStore.showToc || pageStore.showTags" />
<w-separator v-if="showToc || pageStore.showTags" />
<!-- Rating -->
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:star-half-alt" color="grey" />
<div class="text-caption text-grey-7">Rate this page</div>
<div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div>
</div>
<div class="px-4">
<w-rating
@ -169,6 +182,7 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
import { flattenToc } from '@/helpers/toc'
import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor'
@ -181,6 +195,7 @@ import LoadingGeneric from '@/components/LoadingGeneric.vue'
import PageActionsCol from '@/components/PageActionsCol.vue'
import PageHeader from '@/components/PageHeader.vue'
import PageTags from '@/components/PageTags.vue'
import PageToc from '@/components/PageToc.vue'
import SideDialog from '@/components/SideDialog.vue'
const editorComponents = {
@ -231,7 +246,6 @@ const state = reactive({
globalDialogComponent: null,
showTagsEditBtn: false,
tagEditMode: false,
tocExpanded: [],
tocSelected: null,
currentRating: 3
})
@ -247,6 +261,23 @@ const showSidebar = computed(() => {
!editorStore.isActive
)
})
/*
Whether there is a contents SECTION, heading and separator included -- not just whether the page
asked for one. A page with no headings, or whose depth settings leave nothing to list, would
otherwise show "Contents" over an empty space. Asked of the same helper the list itself draws from,
so the two can never disagree about whether a row survives.
*/
const showToc = computed(() => {
if (!pageStore.showToc) {
return false
}
return (
flattenToc(pageStore.toc, {
minDepth: pageStore.tocDepth.min,
maxDepth: pageStore.tocDepth.max
}).length > 0
)
})
const relationsLeft = computed(() => {
return pageStore.relations ? pageStore.relations.filter((r) => r.position === 'left') : []
})
@ -373,47 +404,6 @@ watch(
},
{ immediate: true }
)
watch(
() => pageStore.toc,
() => {
refreshTocExpanded()
},
{ immediate: true }
)
watch(
() => pageStore.tocDepth,
() => {
refreshTocExpanded()
}
)
// METHODS
function refreshTocExpanded(baseToc, lvl) {
const toExpand = []
let isRootNode = false
if (!baseToc) {
baseToc = pageStore.toc
isRootNode = true
lvl = 1
}
if (baseToc.length > 0) {
for (const node of baseToc) {
if (lvl >= pageStore.tocDepth.min && lvl < pageStore.tocDepth.max) {
toExpand.push(node.key)
}
if (node.children?.length && lvl < pageStore.tocDepth.max - 1) {
toExpand.push(...refreshTocExpanded(node.children, lvl + 1))
}
}
}
if (isRootNode) {
state.tocExpanded = toExpand
} else {
return toExpand
}
}
</script>
<style lang="scss">
@ -479,6 +469,28 @@ function refreshTocExpanded(baseToc, lvl) {
// border-top: 1px solid $dark-6;
// }
}
/*
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades
BOTH ways: as a discrete property it flips at the end of the transition when going to hidden, and at
the start when coming back, which is exactly the timing a fade wants.
*/
.tags-edit-btn {
transition:
opacity 0.2s var(--ease-standard),
visibility 0.2s var(--ease-standard);
&.is-hidden {
visibility: hidden;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tags-edit-btn {
transition-duration: 0.01ms;
}
}
.page-sidebar {
flex: 0 0 300px;
@ -501,11 +513,15 @@ function refreshTocExpanded(baseToc, lvl) {
@at-root .body--dark & .w-separator {
--w-hairline-color: #070a0d;
}
}
.page-toc {
&.w-tree--dense .w-tree__node {
padding-bottom: 5px;
}
/*
The column is the height of the shell, so its own content scrolls when there is more of it than
there is room -- a long contents list, in practice. Nothing sticky is involved: the shell holds
still on its own, and the article beside this scrolls in its own box.
*/
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgb(102 102 102 / 0.5) transparent;
}
</style>

@ -22,7 +22,7 @@ import katexHelper from './modules/katex'
import hljs from 'highlight.js'
import { escape, findLast, times } from 'lodash-es'
import { escape } from 'es-toolkit/string'
const quoteStyles = {
chinese: '””‘’',
@ -53,15 +53,31 @@ export class MarkdownRenderer {
} else if (['mermaid', 'plantuml'].includes(lang)) {
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
} else {
const highlighted = lang
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: str }
const lineCount = highlighted.value.match(/\n/g).length
/*
`getLanguage` first, because `hljs.highlight` THROWS on a language it does not know --
`ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes
the first word of a fence's info string as the language name, so a fence whose code starts
on the opening line (``` <!DOCTYPE rfc [) asks for a language called `<!DOCTYPE`, and the
throw took the entire render with it: an empty preview, and -- since the editor patches the
store with the result -- an empty render saved over the stored HTML.
Unknown language therefore falls back to plain code, and the fallback ESCAPES: `str` is the
author's raw source, and the unhighlighted branch used to interpolate it into the markup as
it stood. hljs escapes what it emits, so this only ever affected the unhighlighted path.
*/
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: escape(str) }
// -> `match` is null, not empty, when the code is a single line with no trailing newline
const lineCount = (highlighted.value.match(/\n/g) ?? []).length
const lineNums =
lineCount > 1
? `<span aria-hidden="true" class="line-numbers-rows">${times(lineCount, (n) => '<span></span>').join('')}</span>`
? `<span aria-hidden="true" class="line-numbers-rows">${'<span></span>'.repeat(lineCount)}</span>`
: ''
return `<pre class="codeblock hljs ${lineCount > 1 && 'line-numbers'}"><code class="language-${lang}">${highlighted.value}${lineNums}</code></pre>`
// -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote
// in it would otherwise close the attribute and inject markup into the preview
return `<pre class="codeblock hljs ${lineCount > 1 && 'line-numbers'}"><code class="language-${escape(lang ?? '')}">${highlighted.value}${lineNums}</code></pre>`
}
}
})
@ -196,6 +212,6 @@ export class MarkdownRenderer {
}
getClosestPreviewLine(line) {
return findLast(this.linesMap, (n) => n <= line)
return this.linesMap.findLast((n) => n <= line)
}
}

@ -24,6 +24,14 @@ export const usePageStore = defineStore('page', {
authorName: '',
commentsCount: 0,
content: '',
/**
* Whether `content` above is this page's actual source, rather than just the state it starts in.
*
* The API leaves `content` out of a page unless an editor asked for it and the session may see it,
* so an empty string in this store means either "the page is empty" or "nobody fetched it" and
* `pageSave` must not write the second one over a page that has content. See the guard there.
*/
contentLoaded: false,
createdAt: '',
description: '',
editor: '',
@ -100,6 +108,9 @@ export const usePageStore = defineStore('page', {
// Update page store
this.$patch({
...pageData,
// -> The field is present exactly when the source came with the page, which is what makes
// the copy in this store safe to save; a view-mode load leaves the previous one in place
contentLoaded: Object.hasOwn(pageData, 'content'),
relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
@ -206,6 +217,8 @@ export const usePageStore = defineStore('page', {
relations: [],
tags: [],
content: content ?? '',
// -> A page being created has no stored source to lose: whatever it starts with IS the source
contentLoaded: true,
render: '',
isBrowsable: true,
isSearchable: true,
@ -255,7 +268,20 @@ export const usePageStore = defineStore('page', {
loadArgs.id = this.id
}
await this.pageLoad(loadArgs)
/*
Edits made OUTSIDE the editor have to survive opening it.
The page properties panel writes straight to this store, and the header then offers to save
them so a page can arrive here with a changed title and an unchanged everything else. A full
load would replace every field with what is stored and reset the change timestamps, throwing
those edits away without a word. The source is the only thing missing in that state, so the
source is the only thing fetched.
*/
if (editorStore.hasPendingChanges) {
await this.pageLoadSource()
} else {
await this.pageLoad(loadArgs)
}
if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs()
@ -267,6 +293,32 @@ export const usePageStore = defineStore('page', {
editor: this.editor
})
},
/**
* PAGE - LOAD SOURCE ONLY
*
* Fetches the source and nothing else, for opening the editor on a page whose other fields have
* already been edited elsewhere. Deliberately touches neither the rest of the page nor the editor's
* change timestamps: what is pending stays pending, and stays saveable.
*/
async pageLoadSource() {
const siteStore = useSiteStore()
try {
const pageData = await API_CLIENT.get(`sites/${siteStore.id}/pages/${this.id}`, {
searchParams: { withContent: true }
}).json()
// -> Absent rather than empty means the server withheld it; see `contentLoaded`
if (!Object.hasOwn(pageData ?? {}, 'content')) {
throw new Error('ERR_PAGE_SOURCE_UNAVAILABLE')
}
this.$patch({
content: pageData.content,
contentLoaded: true
})
} catch (err) {
console.warn(err)
throw err
}
},
/**
* PAGE - MOVE
*/
@ -338,6 +390,23 @@ export const usePageStore = defineStore('page', {
])
}
/*
Never save a source this store never received.
An editor that came up empty because the source was withheld an expired session, a failed
load is indistinguishable from an empty page by the time the payload is built, and sending
the empty string replaces the stored HTML's source with nothing. Dropping the key instead
leaves it exactly as it was: `updatePage` only writes `content` when it is not `undefined`.
Typing into an editor sets the flag, so deliberately clearing a page still works that empty
string came from the author, not from a load that never happened. A page being created always
has it set, which is also why this cannot leave the POST short of a required field.
*/
if (!this.contentLoaded) {
delete body.content
console.warn('Page source was never loaded; saving without touching the stored content.')
}
let pageData
if (editorStore.mode === 'create') {
const resp = unwrap(

@ -98,7 +98,6 @@ export const useSiteStore = defineStore('site', {
codeBlocksTheme: '',
sidebarPosition: 'left',
tocPosition: 'right',
showSharingMenu: true,
showPrintBtn: true
},
sideDialogShown: false,

Loading…
Cancel
Save