fix: page scripts/styles + toggle styling in dark mode

scarlett
NGPixel 12 hours ago
parent ad8e9d393f
commit 32a656e7be
No known key found for this signature in database

@ -350,8 +350,10 @@ class Pages {
* Flatten a row and its blobs into the shape the API returns.
*
* @param locked Withhold the body the source, the rendered HTML, the table of contents drawn from
* it, and the relation links written onto the page. The metadata stays: a reader
* looking at the lock screen is told what page they are being asked for a password to.
* it, the relation links written onto the page, and the page's own CSS and scripts,
* which the page view runs against content this reader has not been given. The
* metadata stays: a reader looking at the lock screen is told what page they are
* being asked for a password to.
* @param withPassword Include the page's own password. Only for a requester who may edit the page,
* which is the one that has to be able to read it back and save it again.
* @param withContent Include the source. A redirection's comes back either way: its content is not
@ -405,9 +407,9 @@ class Pages {
showTags: config.showTags ?? true,
showToc: config.showToc ?? true,
tocDepth: config.tocDepth ?? { min: 1, max: 2 },
scriptJsLoad: scripts.jsLoad ?? '',
scriptJsUnload: scripts.jsUnload ?? '',
scriptCss: scripts.css ?? '',
scriptJsLoad: locked ? '' : (scripts.jsLoad ?? ''),
scriptJsUnload: locked ? '' : (scripts.jsUnload ?? ''),
scriptCss: locked ? '' : (scripts.css ?? ''),
navigationId: row.navigationId ?? null,
navigationMode: row.navigationMode ?? 'inherit',
authorId: row.authorId,

@ -166,10 +166,16 @@
<w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip>
</w-btn>
</w-card-section>
<w-card-section class="alt-card" id="refCardScripts">
<!--
Only for an author who may actually write them: the server drops a script or a stylesheet from
somebody without the permission rather than refusing the save, so left on show these buttons
took an edit, closed on it, and lost it without a word.
-->
<w-card-section class="alt-card" id="refCardScripts" v-if="mayWriteScripts || mayWriteStyles">
<div class="w-section-header">{{ t('editor.props.scripts') }}</div>
<w-btn
class="w-full"
v-if="mayWriteScripts"
:label="t(`editor.props.jsLoad`)"
icon="la:js-square"
no-caps
@ -180,6 +186,7 @@
</w-btn>
<w-btn
class="w-full mt-2"
v-if="mayWriteScripts"
:label="t(`editor.props.jsUnload`)"
icon="la:js-square"
no-caps
@ -188,8 +195,11 @@
@click="editScripts(`jsUnload`)">
<w-tooltip>{{ t('editor.props.jsUnloadHint') }}</w-tooltip>
</w-btn>
<!-- -> The gap above it belongs to the buttons before it, so it goes when they do -->
<w-btn
class="w-full mt-2"
class="w-full"
:class="{ 'mt-2': mayWriteScripts }"
v-if="mayWriteStyles"
:label="t(`editor.props.styles`)"
icon="la:css3-alt"
no-caps
@ -355,6 +365,7 @@ import { useEditorStore } from '@/stores/editor'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import IconPickerDialog from './IconPickerDialog.vue'
import PageLocaleRelationsDialog from './PageLocaleRelationsDialog.vue'
@ -368,6 +379,7 @@ const editorStore = useEditorStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
@ -385,17 +397,6 @@ const state = reactive({
showQuickAccess: true
})
const quickaccess = [
{ key: 'refCardInfo', icon: 'la:info-circle', label: t('editor.props.info') },
{ key: 'refCardPublishState', icon: 'la:power-off', label: t('editor.props.publishState') },
{ key: 'refCardRelations', icon: 'la:sun', label: t('editor.props.relations') },
{ key: 'refCardScripts', icon: 'la:code', label: t('editor.props.scripts') },
{ key: 'refCardSidebar', icon: 'la:ruler-vertical', label: t('editor.props.sidebar') },
{ key: 'refCardSocial', icon: 'la:comments', label: t('editor.props.social') },
{ key: 'refCardTags', icon: 'la:tags', label: t('editor.props.tags') },
{ key: 'refCardVisibility', icon: 'la:eye', label: t('editor.props.visibility') }
]
// REFS
const iptTitle = ref(null)
@ -403,6 +404,40 @@ const iptPagePassword = ref(null)
// COMPUTED
/*
Whether this author may write a script or a stylesheet onto THIS page.
Read off `pagePermissions` rather than through `userStore.can()`: both are page rule permissions,
granted per path by a group's rules, and `can()` also answers for the group-wide list -- which is a
broader question ("somewhere") than the one these buttons ask ("here"). It is the same list
`buildScripts` consults on the way in, an administrator holding all of them.
*/
const mayWriteScripts = computed(() => userStore.pagePermissions.includes('write:scripts'))
const mayWriteStyles = computed(() => userStore.pagePermissions.includes('write:styles'))
/*
The rail of jump links down the side of the panel. A computed rather than a constant because the
Scripts section is not always there, and a link to a section that is not rendered is a link that
throws -- `jumpToSection` reads the element straight off the document.
*/
const quickaccess = computed(() =>
[
{ key: 'refCardInfo', icon: 'la:info-circle', label: t('editor.props.info') },
{ key: 'refCardPublishState', icon: 'la:power-off', label: t('editor.props.publishState') },
{ key: 'refCardRelations', icon: 'la:sun', label: t('editor.props.relations') },
{
key: 'refCardScripts',
icon: 'la:code',
label: t('editor.props.scripts'),
shown: mayWriteScripts.value || mayWriteStyles.value
},
{ key: 'refCardSidebar', icon: 'la:ruler-vertical', label: t('editor.props.sidebar') },
{ key: 'refCardSocial', icon: 'la:comments', label: t('editor.props.social') },
{ key: 'refCardTags', icon: 'la:tags', label: t('editor.props.tags') },
{ key: 'refCardVisibility', icon: 'la:eye', label: t('editor.props.visibility') }
].filter((qa) => qa.shown !== false)
)
const publishingRange = computed({
get() {
return {

@ -52,9 +52,10 @@ import UtilCodeEditor from './UtilCodeEditor.vue'
// PROPS
const props = defineProps({
/** Which of the three the dialog is editing: `jsLoad`, `jsUnload` or `styles`. */
mode: {
type: String,
default: 'css'
default: 'styles'
}
})
@ -102,15 +103,28 @@ const languageLabel = computed(() => {
}
})
const contentStoreKey = computed(() => {
return 'script' + props.mode.charAt(0).toUpperCase() + props.mode.slice(1)
})
/*
Which store field this dialog is editing. A table rather than a name built from `mode`, because the
two do not line up: the CSS mode is called `styles` -- it is the button in the properties panel and
the translation key of its label -- while the field it writes is `scriptCss`. Assembled, it spelled
`scriptStyles`, a field the store does not have, so the CSS editor opened empty on a page that had
CSS and saved into nothing.
*/
const STORE_KEYS = {
jsLoad: 'scriptJsLoad',
jsUnload: 'scriptJsUnload',
styles: 'scriptCss'
}
const contentStoreKey = computed(() => STORE_KEYS[props.mode])
// METHODS
function persist() {
// -> `.value`: the computed itself as a key stringifies to `[object Object]`, which is where every
// edit made in this dialog used to go
pageStore.$patch({
[contentStoreKey]: state.content
[contentStoreKey.value]: state.content
})
}
@ -129,7 +143,7 @@ function saveAndClose() {
// -> No deferred mount: the quarter-second wait was there to give the old editor a laid-out container
// to measure itself against, and a textarea needs no such thing
onMounted(() => {
state.content = pageStore[contentStoreKey.value]
state.content = pageStore[contentStoreKey.value] ?? ''
// -> The editor is what this dialog is for, so the caret starts there. After the tick that renders
// the content above, so focus lands on a field that is already populated.
nextTick(() => {

@ -21,8 +21,16 @@
:class="[dense ? 'size-4' : 'size-5', knobOffset]" />
</span>
<span
class="w-toggle__knob inline-flex items-center justify-center rounded-full transition-transform duration-200"
class="w-toggle__knob relative inline-flex items-center justify-center rounded-full"
:class="[dense ? 'size-4' : 'size-5', knobOffset]">
<!--
A second copy of the same glyph, one pixel down-right and under the real one, so the mark
keeps an edge against the knob it sits on. Same light source as the rest of the relief.
-->
<w-icon
class="w-toggle__mark-shadow"
:name="isOn ? 'mdi:check' : 'mdi:close'"
:size="dense ? '11px' : '13px'" />
<w-icon
class="w-toggle__mark"
:name="isOn ? 'mdi:check' : 'mdi:close'"
@ -40,12 +48,17 @@ import { computed } from 'vue'
* On/off switch.
*
* The track is a recessed channel and the knob sits proud of it, lit from the top left; state is
* read from where the knob sits plus the mark at its centre -- a tick when on, a cross when off,
* drawn in a muted tone so it reads as engraved into the knob rather than printed on it.
* read from where the knob sits plus the status tone -- a tick when on, a cross when off.
*
* A disabled switch drops both its relief and its status colour: no glow, a grey mark, and a flat
* track. Dimming alone read as "slightly faded" rather than "not available", and a green tick on a
* control nobody can move says the wrong thing twice over.
* WHICH part wears that tone depends on the theme, and it is the one thing about this control that
* is not just a change of value between the two. On a dark surface the pin itself is the coloured
* element, with a white mark on it, which is what gives the switch something bright to read at a
* glance. On a light one the pin stays near-white and the MARK carries the colour, because a
* saturated pin on a pale page shouts far louder than the same pin does against a dark one.
*
* A disabled switch drops both its relief and its status colour: no glow, a neutral pin, a grey
* mark, and a flat track. Dimming alone read as "slightly faded" rather than "not available", and a
* green mark on a control nobody can move says the wrong thing twice over.
*
* The label's `pt-px` is optical centring, the same compensation WInput makes: Roboto's ascent
* exceeds its descent, so a line box centred by geometry renders its glyphs above the middle of
@ -140,6 +153,12 @@ function toggle() {
into glare, and the rim has to be *lighter* than the track where on a light surface it is white.
*/
.w-toggle {
/*
The status tone, in one place: the glow always takes it, and each theme points either the pin or
the mark at it further down. It lives on the button rather than on the three elements that draw
it, so they cannot disagree about which state they are showing.
*/
--w-toggle-status: var(--color-negative);
--w-toggle-track: #dfe3ea;
--w-toggle-rim: #ffffff;
--w-toggle-knob: #fdfdfe;
@ -148,20 +167,38 @@ function toggle() {
--w-toggle-highlight: rgb(255 255 255 / 0.95);
--w-toggle-cast: rgb(0 0 0 / 0.12);
--w-toggle-glow: 0.62;
--w-toggle-mark: var(--w-toggle-status);
--w-toggle-mark-disabled: #8a8f98;
/*
A pale pin needs no relief under the mark, so the copy is the pin's own tone and draws nothing.
Keeping it painted rather than hidden is what makes the dark theme a change of colour and not a
change of markup.
*/
--w-toggle-mark-shadow: #ffffff;
/* The pin is neutral here either way, so disabled changes only the mark */
--w-toggle-knob-disabled: var(--w-toggle-knob);
}
.w-toggle[aria-checked='true'] {
--w-toggle-status: var(--color-positive);
}
:global(body.body--dark .w-toggle) {
--w-toggle-track: #262c38;
--w-toggle-rim: #39414f;
--w-toggle-knob: #6b7382;
--w-toggle-knob: var(--w-toggle-status);
--w-toggle-knob-rim: rgb(255 255 255 / 0.1);
--w-toggle-shadow: rgb(0 0 0 / 0.6);
--w-toggle-highlight: rgb(255 255 255 / 0.07);
--w-toggle-cast: rgb(0 0 0 / 0.45);
/* Held up a little: the same tone has less to carry against a dark channel than a pale one */
--w-toggle-glow: 0.72;
--w-toggle-mark: #ffffff;
--w-toggle-mark-disabled: #aeb4bf;
/* White on the positive tone is a weak pairing, so the mark gets an edge to read against */
--w-toggle-mark-shadow: rgb(0 0 0 / 0.3);
/* Shed the status colour when the control cannot be moved; this is the pin's old neutral */
--w-toggle-knob-disabled: #6b7382;
}
.w-toggle__glow-clip {
@ -183,7 +220,7 @@ function toggle() {
step with the knob.
*/
.w-toggle__glow {
background-color: var(--color-negative);
background-color: var(--w-toggle-status);
opacity: var(--w-toggle-glow);
filter: blur(9px);
transition:
@ -191,10 +228,6 @@ function toggle() {
background-color 0.2s var(--ease-standard);
}
[aria-checked='true'] .w-toggle__glow {
background-color: var(--color-positive);
}
.w-toggle__track {
background-color: var(--w-toggle-track);
box-shadow:
@ -204,30 +237,50 @@ function toggle() {
2px 3px 6px var(--w-toggle-cast);
}
/*
The transition is declared here rather than with Tailwind's `transition-transform`, because in the
dark theme the pin's colour has to travel with its movement. Same duration and easing as the glow,
which shares the knob's offset class and so must move in step with it.
*/
.w-toggle__knob {
background-color: var(--w-toggle-knob);
box-shadow:
0 0 0 1px var(--w-toggle-knob-rim),
2px 2px 4px var(--w-toggle-shadow),
-2px -2px 4px var(--w-toggle-highlight);
transition:
translate 0.2s var(--ease-standard),
background-color 0.2s var(--ease-standard);
}
/*
The mark takes the theme's own positive/negative tones rather than literal green and red. Those
two are re-mapped at runtime for colour-vision deficiency (see stores/user.js), which is exactly
the case where a green/red pair would otherwise stop distinguishing anything -- and they follow
a site's palette for free.
The status tones are the theme's own rather than literal green and red. Those two are re-mapped at
runtime for colour-vision deficiency (see stores/user.js), which is exactly the case where a
green/red pair would otherwise stop distinguishing anything -- and they follow a site's palette
for free.
It stays a confirmation, not the signal: the knob's position is what announces the state, which
is what keeps this readable when the two tones are indistinguishable to the viewer.
The mark stays a confirmation, not the signal: the knob's position is what announces the state,
which is what keeps this readable when the two tones are indistinguishable to the viewer.
*/
.w-toggle__mark {
color: var(--color-negative);
position: relative;
color: var(--w-toggle-mark);
transition: color 0.2s var(--ease-standard);
}
[aria-checked='true'] .w-toggle__mark {
color: var(--color-positive);
/*
The mark's drop shadow. Both copies are positioned, so painting order is document order and the
real mark covers this one -- `z-index: -1` would put it behind the knob's own background instead.
What it draws is the theme's business, and both answers are in the variable blocks above: a pixel
of dark under a white mark on a coloured pin, or nothing at all on a pale one.
*/
.w-toggle__mark-shadow {
position: absolute;
top: 50%;
left: 50%;
translate: calc(-50% + 1px) calc(-50% + 1px);
color: var(--w-toggle-mark-shadow);
}
/*
@ -241,6 +294,7 @@ function toggle() {
}
.w-toggle--disabled .w-toggle__knob {
background-color: var(--w-toggle-knob-disabled);
box-shadow: 0 0 0 1px var(--w-toggle-knob-rim);
}
@ -258,6 +312,14 @@ function toggle() {
color: var(--w-toggle-mark-disabled);
}
/*
A flat control has nothing for a shadow to be cast onto, and the grey mark is what says the switch
is out of reach -- giving it relief the operable one has would undo that.
*/
.w-toggle--disabled .w-toggle__mark-shadow {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.w-toggle__knob,
.w-toggle__mark,

@ -0,0 +1,179 @@
import { computed, nextTick, onScopeDispose, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
/**
* The style element carrying the current page's CSS. One per document: only one page is on screen at
* a time, so this is replaced rather than added to, and a page without CSS has none at all.
*/
const STYLE_ID = 'page-styles'
/**
* Apply the page's CSS, replacing whatever the previous page had.
*
* Appended to the head, so these rules come after the app's own stylesheets and win a tie on order --
* which is what a per-page override is for. It is not scoped to the article: the field is documented
* as "CSS rules to add to the page", and a page that wants to restyle the header or the sidebar for
* its own visit is the reason it exists.
*
* @param {string} css
*/
function applyStyles(css) {
const existing = document.getElementById(STYLE_ID)
if (!css?.trim()) {
existing?.remove()
return
}
const el = existing ?? document.createElement('style')
el.id = STYLE_ID
el.textContent = css
if (!existing) {
document.head.appendChild(el)
}
}
/**
* Run one of the page's scripts.
*
* A `<script>` element rather than `new Function(code)()`, for three reasons. It runs the code as a
* classic script at top level, so `var`, a function declaration or a `const` the author expects to
* find from an inline handler in the content lands where they expect it. It throws nowhere this
* caller can see -- a broken page script is reported as an uncaught error on `window`, the way a
* broken script in a document is, instead of taking the page view's own code down with it. And under
* a Content-Security-Policy it needs `script-src 'unsafe-inline'` rather than `'unsafe-eval'`, which
* is the lesser of the two to have to allow.
*
* The element is removed the moment it has run: insertion is what executes it and it is inert from
* then on, so leaving it behind would only pile up one dead element per navigation.
*
* @param {string} code
*/
function execute(code) {
if (!code?.trim()) {
return
}
const el = document.createElement('script')
el.type = 'text/javascript'
el.textContent = code
document.body.appendChild(el)
el.remove()
}
/**
* The page's own CSS and Javascript, on the page the reader is looking at.
*
* The three fields the properties panel writes -- `scriptCss`, `scriptJsLoad`, `scriptJsUnload` --
* are stored per page and, until this existed, were never applied to anything: they came down with
* every page and nothing read them. This is where they take effect, in the page view, because that is
* where the page's content actually is.
*
* What runs when:
*
* - **CSS** is in force for as long as the page is on screen, the editor included -- an author
* writing rules for a page wants to see them applied to it, and the dialog patches the store as it
* is saved, so the change shows immediately.
* - **`jsLoad`** runs once the page's content is in the document, which is the tick after the store
* changes: the element the script goes looking for has to exist by then.
* - **`jsUnload`** runs before that content is destroyed -- walking to another page, opening the
* editor over it, or leaving the page view altogether. The code that runs is the one the page on
* screen came with, captured when its load script ran, since by the time a navigation gets here the
* store already holds the page arriving.
*
* What does NOT run either of them is the script itself changing. The properties panel writes these
* fields into the page store as they are edited -- the panel opens over a page being read as well as
* over one being written -- so a watcher on the code would run an author's Javascript the moment they
* pressed Save in the editor dialog, against the page behind it and before it was saved anywhere.
* Arriving at the page is the event; the code is read when it happens.
*
* A page nobody is reading runs nothing: not while the editor is over it -- what is on screen there is
* a preview being typed into rather than the page -- not on a path with no page, and not on a locked
* one, whose body the server never sent.
*
* A full document unload (a reload, a closed tab) is deliberately not one of the cases: the document
* and everything the script did to it are going away regardless, and a `beforeunload` handler that
* runs a page's arbitrary Javascript is a good way to make leaving the wiki slow.
*/
export function usePageScripts() {
const editorStore = useEditorStore()
const pageStore = usePageStore()
const route = useRoute()
/** The unload script of the page currently on screen, kept from when its load script ran. */
let liveUnload = ''
/** Whether there is a page on screen at all -- one that arrived, with a body to style or script. */
const hasPage = computed(
() => Boolean(pageStore.id) && !pageStore.notFound && !pageStore.isLocked
)
watch(() => (hasPage.value ? pageStore.scriptCss : ''), applyStyles, { immediate: true })
/**
* Whether the page on screen is being written rather than read.
*
* Two answers, because neither covers the other. `isActive` is the editor being opened over a page
* from the page's own URL, which is how the Edit button does it -- the route never moves. The route
* test is for arriving at `/_edit/<page>` or `/_create` directly, where the editor is what the
* reader asked for but is not open yet: the page is fetched into the store first and the editor's
* own configuration after that, and in the gap between the two nothing but the URL says that this
* page is not simply being read. That gap is a round trip wide, so it is not one a later re-check
* can close -- the script would have run long before.
*/
const isEditing = computed(
() => editorStore.isActive || /^\/_(edit|create)(\/|$)/.test(route.path)
)
/**
* The page whose Javascript is live: its id while it is being read, and null while it is not -- no
* page, a locked one, or the editor over it.
*/
const liveId = computed(() => (hasPage.value && !isEditing.value ? pageStore.id : null))
/*
So the callback fires on arriving at a page, on leaving one, and on the editor opening and closing
over one, and on nothing else.
A `pre` watcher (the default), which is the whole reason the unload script still has a page to act
on: it runs before the components re-render, so the store already describes the page arriving
while the document still holds the one being left. (The URL has moved on by then too -- the router
navigates first -- so a script reading `location` during unload sees where the reader is going.)
*/
watch(
liveId,
(next) => {
runUnload()
if (!next) {
return
}
// -> The tick that puts the new page's content in the document; the script runs against it
nextTick(() => {
/*
Asked again rather than trusted from the watcher: a tick is long enough for the reader to
have gone somewhere else, and this script is only ever meant to run against the page that is
actually on screen when it does.
*/
if (liveId.value !== next) {
return
}
liveUnload = pageStore.scriptJsUnload
execute(pageStore.scriptJsLoad)
})
},
{ immediate: true }
)
/** Run the on-screen page's unload script, once. */
function runUnload() {
const code = liveUnload
liveUnload = ''
execute(code)
}
// -> The page view itself going away: the search screen, the admin area, a profile page
onScopeDispose(() => {
runUnload()
applyStyles('')
})
}

@ -354,6 +354,7 @@ import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta'
import { useMinWidth } from '@/composables/screen'
import { usePageScripts } from '@/composables/pageScripts'
import { notify } from '@/composables/notify'
import { withViewTransition } from '@/composables/viewTransition'
import { loading } from '@/composables/loading'
@ -421,6 +422,12 @@ const { t } = useI18n()
const dark = useDark()
/*
The page's own CSS and Javascript, from the properties panel. Nothing to hold onto -- it follows the
page store on its own, for as long as this view is mounted.
*/
usePageScripts()
// META
/*

Loading…
Cancel
Save