select styled

This commit is contained in:
Mateusz Gruszczyński
2026-09-07 15:01:58 +02:00
parent 5734fcfe01
commit 87b61d6088
14 changed files with 551 additions and 269 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.65" version = "0.2.66"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.65" version = "0.2.66"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+2 -1
View File
@@ -42,6 +42,7 @@ const MODULES: &[&str] = &[
"url-state", "url-state",
"vendor-libs", "vendor-libs",
"security", "security",
"select",
]; ];
pub fn render_html( pub fn render_html(
@@ -152,7 +153,7 @@ impl<'a> AssetUrls<'a> {
fn entrypoint(&self, name: &str) -> String { fn entrypoint(&self, name: &str) -> String {
let entrypoint = self.url(&format!("js/{name}.js")); let entrypoint = self.url(&format!("js/{name}.js"));
format!( format!(
r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";import {{ initHeaderPreferences }} from "@rustpad/preferences";await initI18n();initHeaderPreferences();await import("{}");</script>"#, r#"<script type="module">import {{ initI18n }} from "@rustpad/i18n";import {{ initHeaderPreferences }} from "@rustpad/preferences";import {{ initSelectControls }} from "@rustpad/select";await initI18n();initHeaderPreferences();await import("{}");initSelectControls();</script>"#,
escape_js_string(&entrypoint) escape_js_string(&entrypoint)
) )
} }
+158 -205
View File
@@ -797,8 +797,7 @@ textarea:focus {
flex: 1; flex: 1;
} }
.editor-toolbar button, .editor-toolbar button {
.editor-toolbar select {
min-height: 34px; min-height: 34px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 7px; border-radius: 7px;
@@ -808,18 +807,12 @@ textarea:focus {
font-size: .78rem; font-size: .78rem;
} }
.editor-toolbar button:hover, .editor-toolbar button:hover {
.editor-toolbar select:hover {
border-color: var(--border); border-color: var(--border);
background: var(--surface-2); background: var(--surface-2);
color: var(--text); color: var(--text);
} }
.editor-toolbar select {
border-color: var(--border);
background: var(--surface-raised);
}
.view-switch { .view-switch {
padding: 3px; padding: 3px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -832,7 +825,6 @@ textarea:focus {
color: var(--text); color: var(--text);
} }
/* Focus mode removes the toolbar row and leaves only compact floating controls. */
.pad-page .toolbar-collapse-toggle { .pad-page .toolbar-collapse-toggle {
display: inline-grid; display: inline-grid;
flex: 0 0 auto; flex: 0 0 auto;
@@ -1271,8 +1263,6 @@ dialog::backdrop {
} }
.toast-region--modal { .toast-region--modal {
/* A manual popover enters the browser top layer above the active dialog.
Reset popover defaults while keeping the regular toast viewport geometry. */
top: auto; top: auto;
left: auto; left: auto;
margin: 0; margin: 0;
@@ -1284,7 +1274,6 @@ dialog::backdrop {
} }
.app-dialog.has-modal-toast-region { .app-dialog.has-modal-toast-region {
/* Fallback for browsers without the Popover API. */
overflow: visible; overflow: visible;
} }
@@ -1399,8 +1388,13 @@ dialog::backdrop {
} }
@keyframes toast-countdown { @keyframes toast-countdown {
from { transform: scaleX(1); } from {
to { transform: scaleX(0); } transform: scaleX(1);
}
to {
transform: scaleX(0);
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
@@ -1697,7 +1691,6 @@ dialog::backdrop {
} }
} }
/* Home: standalone note and workspace are separate choices. */
.home-layout--wide { .home-layout--wide {
width: min(1040px, calc(100% - 32px)); width: min(1040px, calc(100% - 32px));
} }
@@ -1743,7 +1736,6 @@ dialog::backdrop {
} }
} }
/* Collaborative editor additions */
.editor-shell { .editor-shell {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -2008,7 +2000,6 @@ dialog::backdrop {
} }
} }
/* Layout safeguards and home footer */
.app-header__main, .app-header__main,
.document-heading { .document-heading {
min-width: 0; min-width: 0;
@@ -2074,7 +2065,6 @@ dialog::backdrop {
} }
} }
/* Compact line numbers; author labels sit over the edited text, not in the gutter. */
.line-gutter { .line-gutter {
width: 48px; width: 48px;
} }
@@ -2124,7 +2114,6 @@ dialog::backdrop {
} }
} }
/* Editor display preferences and author overlay */
.editor-shell { .editor-shell {
position: relative; position: relative;
} }
@@ -2155,10 +2144,10 @@ dialog::backdrop {
font-size: .72rem; font-size: .72rem;
} }
.editor-controls select { .editor-controls .app-select--field .app-select-trigger {
width: auto;
min-height: 30px; min-height: 30px;
padding: 3px 24px 3px 7px; height: 30px;
padding: 3px 8px;
border-radius: 6px; border-radius: 6px;
font-size: .74rem; font-size: .74rem;
} }
@@ -2230,7 +2219,6 @@ dialog::backdrop {
word-break: normal; word-break: normal;
} }
/* Keep line numbers exactly aligned with the logical text lines. */
.line-gutter { .line-gutter {
box-sizing: border-box; box-sizing: border-box;
font-size: var(--editor-font-size, 18px); font-size: var(--editor-font-size, 18px);
@@ -2247,7 +2235,6 @@ dialog::backdrop {
font-size: calc(var(--editor-font-size, 18px) - 2px); font-size: calc(var(--editor-font-size, 18px) - 2px);
} }
/* Image crop/resize dialog. */
.image-editor-dialog { .image-editor-dialog {
width: min(920px, calc(100% - 28px)); width: min(920px, calc(100% - 28px));
max-width: none; max-width: none;
@@ -2329,42 +2316,17 @@ dialog::backdrop {
font-weight: 700; font-weight: 700;
} }
.image-editor-controls select, .image-editor-controls .app-select--field,
.image-editor-controls input { .image-editor-controls input {
width: 100%; width: 100%;
} }
.image-editor-controls select { .image-editor-controls .app-select--field .app-select-trigger {
min-height: 40px; min-height: 40px;
padding: 0 36px 0 12px; height: 40px;
border: 1px solid var(--border-strong);
border-radius: 9px; border-radius: 9px;
background-color: var(--control-bg);
background-image: linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%),
linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%);
background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px;
background-repeat: no-repeat;
background-size: 5px 5px, 5px 5px;
box-shadow: inset 0 1px 0 var(--wash-soft);
color: var(--text);
font-size: .84rem; font-size: .84rem;
font-weight: 600; font-weight: 600;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.image-editor-controls select:hover {
border-color: var(--select-border);
background-color: var(--surface-hover);
}
.image-editor-controls select:focus,
.image-editor-controls select:focus-visible {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft);
} }
.image-editor-controls input[type="range"] { .image-editor-controls input[type="range"] {
@@ -2470,7 +2432,6 @@ dialog::backdrop {
} }
} }
/* Wider published page workspace. */
.public-header { .public-header {
padding-inline: max(20px, calc((100vw - 1180px) / 2)); padding-inline: max(20px, calc((100vw - 1180px) / 2));
} }
@@ -2492,7 +2453,6 @@ dialog::backdrop {
padding: clamp(24px, 4vw, 52px); padding: clamp(24px, 4vw, 52px);
} }
/* Preview and attachment management */
.preview { .preview {
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -2919,7 +2879,6 @@ dialog::backdrop {
} }
} }
/* Choice shown when a video file is uploaded. */
.video-insert-dialog { .video-insert-dialog {
width: min(560px, calc(100vw - 24px)); width: min(560px, calc(100vw - 24px));
max-width: 560px; max-width: 560px;
@@ -3238,7 +3197,6 @@ dialog::backdrop {
cursor: default; cursor: default;
} }
/* Preview editing and source line numbers. */
.preview { .preview {
position: relative; position: relative;
padding: 12px 24px 12px 62px; padding: 12px 24px 12px 62px;
@@ -3361,7 +3319,6 @@ dialog::backdrop {
color: var(--text-subtle); color: var(--text-subtle);
} }
/* Task-list layout. Keep the checkbox in the same marker gutter as a bullet. */
.markdown-body .task-list-item { .markdown-body .task-list-item {
position: relative; position: relative;
display: block; display: block;
@@ -3755,7 +3712,6 @@ dialog::backdrop {
gap: 6px; gap: 6px;
} }
/* Account footer and authentication dialog. */
.home-footer { .home-footer {
width: min(1040px, calc(100% - 32px)); width: min(1040px, calc(100% - 32px));
margin: auto auto 24px; margin: auto auto 24px;
@@ -4142,7 +4098,6 @@ dialog::backdrop {
background: var(--danger-hover); background: var(--danger-hover);
} }
/* Native hidden must win over component display declarations. */
[hidden] { [hidden] {
display: none !important; display: none !important;
} }
@@ -4364,7 +4319,6 @@ dialog::backdrop {
flex-direction: column; flex-direction: column;
} }
/* Stable scrolling and collapsible Markdown sections. */
.editor-shell { .editor-shell {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -4419,7 +4373,6 @@ dialog::backdrop {
margin-bottom: 0; margin-bottom: 0;
} }
/* Full-height editor: keep header/footer visible and scroll only the work area. */
.pad-page { .pad-page {
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
@@ -4475,7 +4428,6 @@ dialog::backdrop {
} }
} }
/* A collapsible section has one source-line marker; its rendered Markdown does not repeat line numbers inside. */
.markdown-details__content .preview-source-line::before { .markdown-details__content .preview-source-line::before {
display: none; display: none;
} }
@@ -4488,7 +4440,6 @@ dialog::backdrop {
overflow: auto; overflow: auto;
} }
/* Ephemeral room presence and chat */
.room-details { .room-details {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -4685,7 +4636,6 @@ dialog::backdrop {
filter: brightness(1.15); filter: brightness(1.15);
} }
/* Sharing panel */
.share-panel { .share-panel {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -4783,7 +4733,7 @@ dialog::backdrop {
} }
.share-link-row input, .share-link-row input,
.share-link-row select { .share-link-row .app-select--field {
min-width: 0; min-width: 0;
} }
@@ -4811,7 +4761,6 @@ dialog::backdrop {
} }
} }
/* Share dialog */
.share-dialog { .share-dialog {
width: min(900px, calc(100% - 28px)); width: min(900px, calc(100% - 28px));
max-width: 900px; max-width: 900px;
@@ -4904,30 +4853,13 @@ dialog::backdrop {
font-size: .76rem; font-size: .76rem;
} }
.share-dialog select { .share-dialog .app-select--field {
width: 100%; width: 100%;
}
.share-dialog .app-select--field .app-select-trigger {
min-height: 42px; min-height: 42px;
padding: 0 38px 0 12px; height: 42px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background-color: var(--surface-toolbar);
color: var(--text);
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%), linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%);
background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
cursor: pointer;
}
.share-dialog select:hover {
border-color: var(--select-border);
}
.share-dialog select:focus {
border-color: var(--focus);
box-shadow: 0 0 0 3px var(--focus-ring);
outline: none;
} }
.share-dialog input[type="text"], .share-dialog input[type="text"],
@@ -5043,7 +4975,6 @@ dialog::backdrop {
} }
} }
/* Refined sharing dialog and shared-resource attribution. */
.share-dialog { .share-dialog {
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 18px; border-radius: 18px;
@@ -5202,7 +5133,6 @@ dialog::backdrop {
} }
} }
/* Markdown alerts. */
.markdown-body .markdown-alert { .markdown-body .markdown-alert {
margin: 1em 0; margin: 1em 0;
padding: .8em 1em; padding: .8em 1em;
@@ -5239,7 +5169,6 @@ dialog::backdrop {
background: var(--status-danger-bg); background: var(--status-danger-bg);
} }
/* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */
.markdown-body pre.code-with-lines code { .markdown-body pre.code-with-lines code {
counter-reset: none; counter-reset: none;
} }
@@ -5264,7 +5193,6 @@ dialog::backdrop {
user-select: none; user-select: none;
} }
/* Generated table of contents. */
.markdown-body .markdown-toc { .markdown-body .markdown-toc {
margin: 1em 0; margin: 1em 0;
padding: .8em 1em; padding: .8em 1em;
@@ -5278,7 +5206,6 @@ dialog::backdrop {
padding-left: 1.35em; padding-left: 1.35em;
} }
/* Top-level entries are already numbered by their headings, so omit redundant bullets. */
.markdown-body .markdown-toc>ul { .markdown-body .markdown-toc>ul {
padding-left: 0; padding-left: 0;
} }
@@ -5303,7 +5230,6 @@ dialog::backdrop {
margin-left: 0; margin-left: 0;
} }
/* Nested Markdown lists keep markers and source-line numbers in separate gutters. */
.markdown-body ul, .markdown-body ul,
.markdown-body ol { .markdown-body ol {
padding-left: 1.75em; padding-left: 1.75em;
@@ -5323,7 +5249,6 @@ dialog::backdrop {
list-style: none; list-style: none;
} }
/* Keep the editor gutter inside the mobile workspace. */
@media (max-width: 720px) { @media (max-width: 720px) {
.pad-page .editor-shell { .pad-page .editor-shell {
grid-template-columns: 38px minmax(0, 1fr); grid-template-columns: 38px minmax(0, 1fr);
@@ -5518,7 +5443,6 @@ dialog::backdrop {
gap: 10px; gap: 10px;
} }
/* Per-character authorship overlay. The textarea remains the editable surface. */
.authorship-layer { .authorship-layer {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@@ -5560,7 +5484,6 @@ dialog::backdrop {
} }
} }
/* Multiple authors can contribute to one line. */
.owner-label-group { .owner-label-group {
position: absolute; position: absolute;
right: calc(var(--editor-rendered-font-size, 14px) * .57); right: calc(var(--editor-rendered-font-size, 14px) * .57);
@@ -5581,12 +5504,10 @@ dialog::backdrop {
transform: none; transform: none;
} }
/* Keep public TOC targets visible below the sticky page header. */
.public-content :is(h1, h2, h3, h4, h5, h6)[id] { .public-content :is(h1, h2, h3, h4, h5, h6)[id] {
scroll-margin-top: 88px; scroll-margin-top: 88px;
} }
/* Shared semantic action buttons. Keep legacy class names as aliases. */
.action-button, .action-button,
.primary-button, .primary-button,
.secondary-button, .secondary-button,
@@ -5964,8 +5885,6 @@ dialog::backdrop {
} }
} }
/* Keep the view controls visible on Full HD displays using 125% scaling.
Only the formatting group becomes horizontally scrollable. */
@media (min-width: 1500px) and (max-width: 1699px) { @media (min-width: 1500px) and (max-width: 1699px) {
.pad-page .editor-toolbar { .pad-page .editor-toolbar {
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -5995,7 +5914,6 @@ dialog::backdrop {
} }
} }
/* Responsive note editor -------------------------------------------------- */
@media (max-width: 1499px) { @media (max-width: 1499px) {
.pad-page { .pad-page {
display: flex; display: flex;
@@ -6329,8 +6247,7 @@ dialog::backdrop {
padding: 6px; padding: 6px;
} }
.pad-page .editor-toolbar button, .pad-page .editor-toolbar button {
.pad-page .editor-toolbar select {
min-height: 32px; min-height: 32px;
padding-inline: 8px; padding-inline: 8px;
} }
@@ -6357,7 +6274,6 @@ dialog::backdrop {
cursor: not-allowed; cursor: not-allowed;
} }
/* Per-note author color override */
.use-global-color { .use-global-color {
display: inline-grid; display: inline-grid;
width: 1.75rem; width: 1.75rem;
@@ -6376,7 +6292,6 @@ dialog::backdrop {
color: var(--text); color: var(--text);
} }
/* Quick actions appear when the editor header switches to its compact menu. */
.mobile-editor-bubble { .mobile-editor-bubble {
display: none; display: none;
} }
@@ -6501,7 +6416,6 @@ dialog::backdrop {
} }
} }
/* Movable quick actions for compact editor screens. */
@media (max-width: 1499px) { @media (max-width: 1499px) {
.pad-page .mobile-editor-bubble { .pad-page .mobile-editor-bubble {
user-select: none; user-select: none;
@@ -6563,7 +6477,6 @@ dialog::backdrop {
} }
} }
/* Authorship display modes */
.editor-column-label { .editor-column-label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6643,7 +6556,6 @@ dialog::backdrop {
} }
} }
/* Keep the whole editor surface consistent in Simple and Full modes. */
.editor-shell { .editor-shell {
background: var(--surface-inset); background: var(--surface-inset);
} }
@@ -6666,7 +6578,6 @@ dialog::backdrop {
z-index: 5; z-index: 5;
} }
/* Stable editor canvas in both authorship modes. */
.pad-page .editor-column, .pad-page .editor-column,
.pad-page .editor-shell { .pad-page .editor-shell {
background: var(--surface-inset); background: var(--surface-inset);
@@ -6690,7 +6601,6 @@ dialog::backdrop {
background: transparent !important; background: transparent !important;
} }
/* The authorship canvas must paint the whole editable area, not only text rows. */
.pad-page .authorship-layer { .pad-page .authorship-layer {
width: auto; width: auto;
height: auto; height: auto;
@@ -6714,7 +6624,6 @@ dialog::backdrop {
line-height: 1.45; line-height: 1.45;
} }
/* Full-height editor/gutter separator, independent of the number of text lines. */
.pad-page .editor-shell::after { .pad-page .editor-shell::after {
position: absolute; position: absolute;
z-index: 6; z-index: 6;
@@ -6744,9 +6653,6 @@ dialog::backdrop {
} }
} }
/* Fill the editor column with the actual editor shell.
The grid version could size the shell to its content in Full authorship mode,
so the gutter separator stopped after the last rendered line. */
.pad-page .editor-column { .pad-page .editor-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -6765,7 +6671,6 @@ dialog::backdrop {
min-height: 0; min-height: 0;
} }
/* Published page action and settings in one dropdown. */
.page-settings { .page-settings {
position: relative; position: relative;
} }
@@ -6880,7 +6785,6 @@ dialog::backdrop {
background: var(--surface-hover); background: var(--surface-hover);
} }
/* Keep the Simple/Full switch inside the editor label frame. */
.authorship-mode-control { .authorship-mode-control {
padding: 1px; padding: 1px;
border-radius: 7px; border-radius: 7px;
@@ -6894,7 +6798,6 @@ dialog::backdrop {
line-height: 20px; line-height: 20px;
} }
/* Final UI fixes: opaque Page settings and per-note authorship controls. */
.page-settings-menu { .page-settings-menu {
background: var(--surface-card); background: var(--surface-card);
opacity: 1; opacity: 1;
@@ -6947,7 +6850,6 @@ dialog::backdrop {
} }
} }
/* Shared editor display settings */
.authorship-controls { .authorship-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -7020,7 +6922,6 @@ dialog::backdrop {
white-space: pre-wrap; white-space: pre-wrap;
} }
/* Keep generated individual links below the new-link form. */
.share-link-list-wrap { .share-link-list-wrap {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -7080,7 +6981,6 @@ dialog::backdrop {
} }
} }
/* Upload progress toast --------------------------------------------------- */
.toast-card--upload { .toast-card--upload {
grid-template-columns: minmax(0, 1fr) 28px; grid-template-columns: minmax(0, 1fr) 28px;
} }
@@ -7133,9 +7033,17 @@ dialog::backdrop {
} }
@keyframes upload-toast-indeterminate { @keyframes upload-toast-indeterminate {
0% { transform: translateX(-120%); } 0% {
50% { transform: translateX(180%); } transform: translateX(-120%);
100% { transform: translateX(420%); } }
50% {
transform: translateX(180%);
}
100% {
transform: translateX(420%);
}
} }
.upload-toast__meta { .upload-toast__meta {
@@ -7195,7 +7103,6 @@ dialog::backdrop {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong)) !important; border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong)) !important;
} }
/* Optional links to exact editor lines. */
.line-number-button { .line-number-button {
display: block; display: block;
width: 100%; width: 100%;
@@ -7233,7 +7140,6 @@ dialog::backdrop {
color: var(--text); color: var(--text);
} }
/* Personal editor preferences in the compact mobile action bar. */
@media (max-width: 1499px) { @media (max-width: 1499px) {
.mobile-editor-options { .mobile-editor-options {
position: relative; position: relative;
@@ -7288,14 +7194,17 @@ dialog::backdrop {
font-size: .78rem; font-size: .78rem;
} }
.mobile-editor-options__panel select { .mobile-editor-options__panel .app-select--field {
width: 100%; width: 100%;
}
.mobile-editor-options__panel .app-select--field .app-select-trigger {
min-height: 34px; min-height: 34px;
height: 34px;
} }
} }
/* Note editor polish: clearer actions, lighter canvas, and aligned split columns. */
.pad-page .header-actions>.secondary-button:not(.danger-button), .pad-page .header-actions>.secondary-button:not(.danger-button),
.pad-page .page-settings>summary.secondary-button { .pad-page .page-settings>summary.secondary-button {
border-color: var(--note-action-border); border-color: var(--note-action-border);
@@ -7395,7 +7304,6 @@ dialog::backdrop {
display: none; display: none;
} }
/* Unified RustPad resource identity used by notes and workspaces. */
.resource-brand { .resource-brand {
display: grid; display: grid;
flex: 0 0 auto; flex: 0 0 auto;
@@ -7464,7 +7372,6 @@ dialog::backdrop {
} }
} }
/* Search, pagination and theme-aware scrollbars. */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-strong) var(--surface-inset); scrollbar-color: var(--border-strong) var(--surface-inset);
@@ -7520,36 +7427,14 @@ dialog::backdrop {
white-space: nowrap; white-space: nowrap;
} }
.page-size-label select { .page-size-label .app-select--field {
min-width: 78px; min-width: 78px;
}
.page-size-label .app-select--field .app-select-trigger {
min-height: 40px; min-height: 40px;
padding: 0 34px 0 12px; height: 40px;
border: 1px solid var(--border-strong);
border-radius: 10px; border-radius: 10px;
background-color: var(--surface-toolbar);
background-image: linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%),
linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%);
background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px;
background-repeat: no-repeat;
background-size: 5px 5px, 5px 5px;
box-shadow: inset 0 1px 0 var(--wash-soft);
color: var(--text);
font-weight: 700;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
}
.page-size-label select:hover {
border-color: var(--select-border);
background-color: var(--surface-hover);
}
.page-size-label select:focus-visible {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring), inset 0 1px 0 var(--wash-soft);
} }
.page-size-label:focus-within { .page-size-label:focus-within {
@@ -7591,8 +7476,6 @@ dialog::backdrop {
} }
} }
/* Share links: keep one-time URLs readable without storing plaintext tokens. */
.share-link-once { .share-link-once {
color: var(--muted-2) !important; color: var(--muted-2) !important;
} }
@@ -7601,7 +7484,6 @@ dialog::backdrop {
color: var(--warning); color: var(--warning);
} }
/* Keep editor mode/view controls inside the viewport at browser zoom. */
.control-label-short { .control-label-short {
display: none; display: none;
} }
@@ -7674,7 +7556,6 @@ dialog::backdrop {
} }
} }
/* History content wraps vertically and never creates a horizontal scrollbar. */
.history-panel { .history-panel {
max-width: 100vw; max-width: 100vw;
min-width: 0; min-width: 0;
@@ -7838,7 +7719,6 @@ dialog::backdrop {
font-size: .68rem; font-size: .68rem;
} }
/* Mobile upload action shown directly below the formatting controls. */
.mobile-upload-button { .mobile-upload-button {
display: none; display: none;
} }
@@ -7906,16 +7786,10 @@ dialog::backdrop {
} }
} }
/* Published-note permalinks ------------------------------------------------ */
.public-content .preview-source-line { .public-content .preview-source-line {
scroll-margin-top: 88px; scroll-margin-top: 88px;
} }
/*
* Line linking is opt-in on published notes. When regular line numbers are
* hidden, enabling links restores the same gutter so link targets stay inside
* the document instead of protruding past its left edge.
*/
.public-page .public-content { .public-page .public-content {
transition: padding-left .18s ease; transition: padding-left .18s ease;
} }
@@ -7924,11 +7798,6 @@ dialog::backdrop {
padding-left: clamp(24px, 4vw, 52px); padding-left: clamp(24px, 4vw, 52px);
} }
/*
* The permalink occupies the same gutter slot as the regular source number.
* It stays invisible until the row is approached, then behaves like the
* clickable editor line number and remains visible briefly after copying.
*/
.public-content .public-permalink { .public-content .public-permalink {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -7992,7 +7861,8 @@ dialog::backdrop {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 24%, transparent);
} }
@media (hover: none), (pointer: coarse) { @media (hover: none),
(pointer: coarse) {
.public-page.line-links-enabled .public-content .public-permalink { .public-page.line-links-enabled .public-content .public-permalink {
opacity: .62; opacity: .62;
transform: none; transform: none;
@@ -8009,7 +7879,6 @@ dialog::backdrop {
} }
} }
/* Per-user, per-note navigation-bar focus mode ---------------------------- */
.pad-page .navbar-collapse-toggle { .pad-page .navbar-collapse-toggle {
position: fixed; position: fixed;
top: max(3px, env(safe-area-inset-top)); top: max(3px, env(safe-area-inset-top));
@@ -8094,7 +7963,8 @@ dialog::backdrop {
flex: 1 1 0; flex: 1 1 0;
} }
@media (hover: none), (pointer: coarse) { @media (hover: none),
(pointer: coarse) {
.pad-page .navbar-collapse-toggle { .pad-page .navbar-collapse-toggle {
width: 32px; width: 32px;
color: var(--text); color: var(--text);
@@ -8103,7 +7973,6 @@ dialog::backdrop {
} }
} }
/* Compact interface preferences in application headers. */
.header-preferences { .header-preferences {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -8111,20 +7980,20 @@ dialog::backdrop {
flex: 0 0 auto; flex: 0 0 auto;
} }
.header-preference-picker { .app-select {
position: relative; position: relative;
flex: 0 0 auto; flex: 0 0 auto;
} }
.header-preference-picker > summary { .app-select>summary {
list-style: none; list-style: none;
} }
.header-preference-picker > summary::-webkit-details-marker { .app-select>summary::-webkit-details-marker {
display: none; display: none;
} }
.header-preference-trigger { .app-select-trigger {
display: inline-flex; display: inline-flex;
min-width: 38px; min-width: 38px;
height: 36px; height: 36px;
@@ -8141,19 +8010,25 @@ dialog::backdrop {
transition: background-color .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease; transition: background-color .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease;
} }
.header-preference-trigger:hover, .app-select-trigger:hover,
.header-preference-picker[open] > .header-preference-trigger { .app-select[open]>.app-select-trigger,
.app-select.is-open>.app-select-trigger {
border-color: var(--border-strong); border-color: var(--border-strong);
background: var(--surface-3); background: var(--surface-3);
color: var(--text); color: var(--text);
} }
.header-preference-trigger:focus-visible { .app-select-trigger:focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--focus);
outline-offset: 2px; outline-offset: 2px;
} }
.header-preference-trigger__icon { .app-select-trigger:disabled {
cursor: default;
opacity: .6;
}
.app-select-trigger__icon {
display: grid; display: grid;
width: 17px; width: 17px;
height: 17px; height: 17px;
@@ -8161,7 +8036,7 @@ dialog::backdrop {
place-items: center; place-items: center;
} }
.header-preference-trigger__icon svg { .app-select-trigger__icon svg {
width: 17px; width: 17px;
height: 17px; height: 17px;
fill: none; fill: none;
@@ -8171,7 +8046,7 @@ dialog::backdrop {
stroke-width: 1.7; stroke-width: 1.7;
} }
.header-preference-trigger__value { .app-select-trigger__value {
max-width: 72px; max-width: 72px;
overflow: hidden; overflow: hidden;
color: var(--text); color: var(--text);
@@ -8182,24 +8057,25 @@ dialog::backdrop {
white-space: nowrap; white-space: nowrap;
} }
.header-preference-picker--language .header-preference-trigger__value { .app-select--language .app-select-trigger__value {
min-width: 18px; min-width: 18px;
text-align: center; text-align: center;
letter-spacing: .04em; letter-spacing: .04em;
} }
.header-preference-trigger__chevron { .app-select-trigger__chevron {
color: var(--muted-2); color: var(--muted-2);
font-size: .68rem; font-size: .68rem;
line-height: 1; line-height: 1;
transition: transform .14s ease; transition: transform .14s ease;
} }
.header-preference-picker[open] .header-preference-trigger__chevron { .app-select[open] .app-select-trigger__chevron,
.app-select.is-open .app-select-trigger__chevron {
transform: rotate(180deg); transform: rotate(180deg);
} }
.header-preference-menu { .app-select-menu {
position: absolute; position: absolute;
top: calc(100% + 7px); top: calc(100% + 7px);
right: 0; right: 0;
@@ -8216,11 +8092,15 @@ dialog::backdrop {
box-shadow: 0 14px 34px var(--shadow-38), inset 0 1px 0 var(--wash-soft); box-shadow: 0 14px 34px var(--shadow-38), inset 0 1px 0 var(--wash-soft);
} }
.header-preference-picker--language .header-preference-menu { .app-select-menu[hidden] {
display: none;
}
.app-select--language .app-select-menu {
min-width: 190px; min-width: 190px;
} }
.header-preference-option { .app-select-option {
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 18px minmax(0, 1fr);
align-items: center; align-items: center;
@@ -8235,19 +8115,24 @@ dialog::backdrop {
text-align: left; text-align: left;
} }
.header-preference-option:hover, .app-select-option:hover,
.header-preference-option:focus-visible { .app-select-option:focus-visible {
border-color: var(--border-strong); border-color: var(--border-strong);
outline: 0; outline: 0;
background: var(--surface-hover); background: var(--surface-hover);
} }
.header-preference-option[aria-selected="true"] { .app-select-option[aria-selected="true"] {
border-color: var(--accent-a35); border-color: var(--accent-a35);
background: var(--accent-soft); background: var(--accent-soft);
} }
.header-preference-option__check { .app-select-option:disabled {
cursor: default;
opacity: .48;
}
.app-select-option__check {
display: grid; display: grid;
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -8257,7 +8142,7 @@ dialog::backdrop {
font-weight: 900; font-weight: 900;
} }
.header-preference-option__copy { .app-select-option__copy {
overflow: hidden; overflow: hidden;
font-size: .78rem; font-size: .78rem;
font-weight: 650; font-weight: 650;
@@ -8265,19 +8150,87 @@ dialog::backdrop {
white-space: nowrap; white-space: nowrap;
} }
.app-select-option__group {
display: block;
margin-bottom: 1px;
color: var(--muted-2);
font-size: .65rem;
font-weight: 700;
}
.app-select-native {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
border: 0 !important;
white-space: nowrap !important;
opacity: 0 !important;
pointer-events: none !important;
}
.app-select--field {
display: inline-block;
max-width: 100%;
vertical-align: middle;
}
.app-select--field .app-select-trigger {
width: 100%;
min-width: 0;
min-height: 40px;
height: 40px;
justify-content: space-between;
padding: 0 11px 0 12px;
border-color: var(--border-strong);
background: var(--surface-toolbar);
color: var(--text);
font-size: .82rem;
font-weight: 700;
}
.app-select--field .app-select-trigger__value {
flex: 1 1 auto;
max-width: none;
color: inherit;
font-size: inherit;
font-weight: inherit;
text-align: left;
}
.app-select-menu--portal {
position: fixed;
top: 0;
right: auto;
left: 0;
z-index: 10000;
min-width: 0;
max-height: min(320px, calc(100dvh - 16px));
overflow: auto;
overscroll-behavior: contain;
}
.app-select-menu--portal .app-select-option__copy {
max-width: min(320px, calc(100vw - 72px));
}
.home-header { .home-header {
justify-content: space-between; justify-content: space-between;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.header-preference-trigger { .app-select--header>.app-select-trigger {
min-width: 36px; min-width: 36px;
height: 34px; height: 34px;
padding-inline: 8px; padding-inline: 8px;
} }
.header-preference-picker--theme .header-preference-trigger__value, .app-select--header.app-select--theme .app-select-trigger__value,
.header-preference-trigger__chevron { .app-select--header .app-select-trigger__chevron {
display: none; display: none;
} }
@@ -8298,18 +8251,18 @@ dialog::backdrop {
gap: 4px; gap: 4px;
} }
.header-preference-trigger { .app-select--header>.app-select-trigger {
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
height: 34px; height: 34px;
padding: 0; padding: 0;
} }
.header-preference-trigger__value { .app-select--header .app-select-trigger__value {
display: none; display: none;
} }
.header-preference-menu { .app-select--header .app-select-menu {
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
} }
+4 -4
View File
@@ -120,13 +120,13 @@
</div> </div>
<button id="mobile-upload-button" class="mobile-upload-button" type="button" <button id="mobile-upload-button" class="mobile-upload-button" type="button"
title="Upload a file"><span aria-hidden="true"></span> Upload</button> title="Upload a file"><span aria-hidden="true"></span> Upload</button>
<div class="editor-controls"><label>Font<select id="font-family"> <div class="editor-controls"><label>Font<select class="app-select-input" id="font-family">
<option value="mono">Mono</option> <option value="mono">Mono</option>
<option value="system">System</option> <option value="system">System</option>
<option value="serif">Serif</option> <option value="serif">Serif</option>
<option value="arial">Arial</option> <option value="arial">Arial</option>
<option value="georgia">Georgia</option> <option value="georgia">Georgia</option>
</select></label><label>Size<select id="font-size"> </select></label><label>Size<select class="app-select-input" id="font-size">
<option value="14" selected>14</option> <option value="14" selected>14</option>
<option value="16">16</option> <option value="16">16</option>
<option value="18">18</option> <option value="18">18</option>
@@ -338,14 +338,14 @@
<details id="mobile-editor-options" class="mobile-editor-options"> <details id="mobile-editor-options" class="mobile-editor-options">
<summary title="Editor options" aria-label="Open editor options"></summary> <summary title="Editor options" aria-label="Open editor options"></summary>
<div class="mobile-editor-options__panel"> <div class="mobile-editor-options__panel">
<label>Font<select id="mobile-font-family"> <label>Font<select class="app-select-input" id="mobile-font-family">
<option value="mono">Mono</option> <option value="mono">Mono</option>
<option value="system">System</option> <option value="system">System</option>
<option value="serif">Serif</option> <option value="serif">Serif</option>
<option value="arial">Arial</option> <option value="arial">Arial</option>
<option value="georgia">Georgia</option> <option value="georgia">Georgia</option>
</select></label> </select></label>
<label>Size<select id="mobile-font-size"> <label>Size<select class="app-select-input" id="mobile-font-size">
<option value="14">14</option> <option value="14">14</option>
<option value="16">16</option> <option value="16">16</option>
<option value="18">18</option> <option value="18">18</option>
+1 -1
View File
@@ -152,7 +152,7 @@
<div class="list-controls resources-controls"> <div class="list-controls resources-controls">
<label class="list-search"><span class="sr-only">Search notes and workspaces</span><input id="resources-search" <label class="list-search"><span class="sr-only">Search notes and workspaces</span><input id="resources-search"
type="search" placeholder="Search notes and workspaces…" autocomplete="off"></label> type="search" placeholder="Search notes and workspaces…" autocomplete="off"></label>
<label class="page-size-label">Per page<select id="resources-per-page"> <label class="page-size-label">Per page<select class="app-select-input" id="resources-per-page">
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
+3 -3
View File
@@ -233,12 +233,12 @@ async function loadResources() {
<div class="share-dialog-body"> <div class="share-dialog-body">
<section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing RustPad users by e-mail or account/directory username.</p></div></div><form data-user-share-form class="share-form-grid"> <section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing RustPad users by e-mail or account/directory username.</p></div></div><form data-user-share-form class="share-form-grid">
<label class="share-emails"><span>E-mail or username</span><input name="recipients" type="text" placeholder="alice@example.com, jsmith" autocomplete="off"></label> <label class="share-emails"><span>E-mail or username</span><input name="recipients" type="text" placeholder="alice@example.com, jsmith" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label> <label><span>Permission</span><select class="app-select-input" name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<button class="primary-button" type="submit">Grant access</button> <button class="primary-button" type="submit">Grant access</button>
</form><div data-user-list class="share-list"></div></section> </form><div data-user-list class="share-list"></div></section>
<section class="share-section" data-direct-links-section><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form"> <section class="share-section" data-direct-links-section><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form">
<label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" autocomplete="off"></label> <label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label> <label><span>Permission</span><select class="app-select-input" name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label> <label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label>
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label> <label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
<button class="primary-button" type="submit">Create link</button> <button class="primary-button" type="submit">Create link</button>
@@ -281,7 +281,7 @@ async function loadResources() {
const linkPreview = visibleLink const linkPreview = visibleLink
? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>` ? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>`
: `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`; : `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`;
return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`; return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select class="app-select-input" name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
}).join("") : '<p class="share-empty">No active links.</p>'; }).join("") : '<p class="share-empty">No active links.</p>';
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } })); userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => { linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => {
+2 -2
View File
@@ -26,9 +26,9 @@ export async function prepareImageFile(file) {
<div class="image-editor-head"><div><h2>Adjust image</h2><p>Keep the whole image or choose a crop, then select output size.</p></div><button class="icon-button" value="cancel" aria-label="Close">×</button></div> <div class="image-editor-head"><div><h2>Adjust image</h2><p>Keep the whole image or choose a crop, then select output size.</p></div><button class="icon-button" value="cancel" aria-label="Close">×</button></div>
<div class="image-crop-stage"><canvas></canvas></div> <div class="image-crop-stage"><canvas></canvas></div>
<div class="image-editor-controls"> <div class="image-editor-controls">
<label>Crop<select data-aspect><option value="original" selected>Whole image</option><option value="free">Free</option><option value="1">Square</option><option value="1.333333">4:3</option><option value="1.777778">16:9</option></select></label> <label>Crop<select class="app-select-input" data-aspect><option value="original" selected>Whole image</option><option value="free">Free</option><option value="1">Square</option><option value="1.333333">4:3</option><option value="1.777778">16:9</option></select></label>
<label>Zoom<input data-zoom type="range" min="1" max="3" value="1" step="0.01"></label> <label>Zoom<input data-zoom type="range" min="1" max="3" value="1" step="0.01"></label>
<label>Max size<select data-size><option value="320">320 px</option><option value="480">480 px</option><option value="640">640 px</option><option value="800">800 px</option><option value="1000">1000 px</option><option value="1200">1200 px</option><option value="1600">1600 px</option><option value="2000">2000 px</option><option value="0" selected>Original</option></select></label> <label>Max size<select class="app-select-input" data-size><option value="320">320 px</option><option value="480">480 px</option><option value="640">640 px</option><option value="800">800 px</option><option value="1000">1000 px</option><option value="1200">1200 px</option><option value="1600">1600 px</option><option value="2000">2000 px</option><option value="0" selected>Original</option></select></label>
</div> </div>
<div class="image-editor-actions"><button class="secondary-button" value="cancel">Cancel</button><button type="button" class="primary-button" data-apply>Use image</button></div> <div class="image-editor-actions"><button class="secondary-button" value="cancel">Cancel</button><button type="button" class="primary-button" data-apply>Use image</button></div>
</form>`; </form>`;
+5
View File
@@ -29,6 +29,7 @@ import { toast } from "@rustpad/toast";
import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n"; import { formatDateTime, formatNumber, formatTime, t, translateSource } from "@rustpad/i18n";
import { getTheme } from "@rustpad/theme"; import { getTheme } from "@rustpad/theme";
import { isResourceAccessError } from "@rustpad/security"; import { isResourceAccessError } from "@rustpad/security";
import { syncSelectControl } from "@rustpad/select";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs"; import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
export function startNoteEditor(adapter) { export function startNoteEditor(adapter) {
@@ -169,6 +170,10 @@ export function startNoteEditor(adapter) {
if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked; if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked;
if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked; if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked;
if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked; if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked;
syncSelectControl(fontFamily);
syncSelectControl(fontSize);
if (mobileFontFamily) syncSelectControl(mobileFontFamily);
if (mobileFontSize) syncSelectControl(mobileFontSize);
} }
syncMobileEditorControls(); syncMobileEditorControls();
function updateAuthorshipControls() { function updateAuthorshipControls() {
+25 -21
View File
@@ -1,6 +1,10 @@
/* /*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed. * Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/ */
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
@@ -28,7 +32,7 @@ function icon(name) {
} }
function closeOtherPickers(except = null) { function closeOtherPickers(except = null) {
document.querySelectorAll(".header-preference-picker[open]").forEach(details => { document.querySelectorAll(".app-select--header[open]").forEach(details => {
if (details !== except) details.open = false; if (details !== except) details.open = false;
}); });
} }
@@ -36,19 +40,19 @@ function closeOtherPickers(except = null) {
function preferenceButton({ value, label, selected, languageCode }) { function preferenceButton({ value, label, selected, languageCode }) {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "header-preference-option"; button.className = "app-select-option";
button.dataset.value = value; button.dataset.value = value;
button.setAttribute("role", "option"); button.setAttribute("role", "option");
button.setAttribute("aria-selected", selected ? "true" : "false"); button.setAttribute("aria-selected", selected ? "true" : "false");
if (languageCode) button.lang = languageCode; if (languageCode) button.lang = languageCode;
const check = document.createElement("span"); const check = document.createElement("span");
check.className = "header-preference-option__check"; check.className = "app-select-option__check";
check.setAttribute("aria-hidden", "true"); check.setAttribute("aria-hidden", "true");
check.textContent = selected ? "✓" : ""; check.textContent = selected ? "✓" : "";
const copy = document.createElement("span"); const copy = document.createElement("span");
copy.className = "header-preference-option__copy"; copy.className = "app-select-option__copy";
copy.textContent = label; copy.textContent = label;
button.append(check, copy); button.append(check, copy);
@@ -57,29 +61,29 @@ function preferenceButton({ value, label, selected, languageCode }) {
function createPicker(kind) { function createPicker(kind) {
const details = document.createElement("details"); const details = document.createElement("details");
details.className = `header-preference-picker header-preference-picker--${kind}`; details.className = `app-select app-select--header app-select--${kind}`;
details.dataset.preferenceKind = kind; details.dataset.preferenceKind = kind;
const summary = document.createElement("summary"); const summary = document.createElement("summary");
summary.className = "header-preference-trigger"; summary.className = "app-select-trigger";
const iconWrap = document.createElement("span"); const iconWrap = document.createElement("span");
iconWrap.className = "header-preference-trigger__icon"; iconWrap.className = "app-select-trigger__icon";
iconWrap.innerHTML = icon(kind === "theme" ? getTheme() : "language"); iconWrap.innerHTML = icon(kind === "theme" ? getTheme() : "language");
const compactValue = document.createElement("span"); const compactValue = document.createElement("span");
compactValue.className = "header-preference-trigger__value"; compactValue.className = "app-select-trigger__value";
compactValue.setAttribute("aria-hidden", "true"); compactValue.setAttribute("aria-hidden", "true");
const chevron = document.createElement("span"); const chevron = document.createElement("span");
chevron.className = "header-preference-trigger__chevron"; chevron.className = "app-select-trigger__chevron";
chevron.setAttribute("aria-hidden", "true"); chevron.setAttribute("aria-hidden", "true");
chevron.textContent = "▾"; chevron.textContent = "▾";
summary.append(iconWrap, compactValue, chevron); summary.append(iconWrap, compactValue, chevron);
const menu = document.createElement("div"); const menu = document.createElement("div");
menu.className = "header-preference-menu"; menu.className = "app-select-menu";
menu.setAttribute("role", "listbox"); menu.setAttribute("role", "listbox");
details.append(summary, menu); details.append(summary, menu);
@@ -102,9 +106,9 @@ function renderLanguagePicker(details) {
const languages = getAvailableLanguages(); const languages = getAvailableLanguages();
const current = getLanguage(); const current = getLanguage();
const selected = languages.find(language => language.code === current) || languages[0]; const selected = languages.find(language => language.code === current) || languages[0];
const summary = details.querySelector(".header-preference-trigger"); const summary = details.querySelector(".app-select-trigger");
const value = details.querySelector(".header-preference-trigger__value"); const value = details.querySelector(".app-select-trigger__value");
const menu = details.querySelector(".header-preference-menu"); const menu = details.querySelector(".app-select-menu");
const label = t("common.language", {}, "Language"); const label = t("common.language", {}, "Language");
summary.setAttribute("aria-label", label); summary.setAttribute("aria-label", label);
@@ -121,10 +125,10 @@ function renderLanguagePicker(details) {
function renderThemePicker(details) { function renderThemePicker(details) {
const current = getTheme(); const current = getTheme();
const summary = details.querySelector(".header-preference-trigger"); const summary = details.querySelector(".app-select-trigger");
const iconWrap = details.querySelector(".header-preference-trigger__icon"); const iconWrap = details.querySelector(".app-select-trigger__icon");
const value = details.querySelector(".header-preference-trigger__value"); const value = details.querySelector(".app-select-trigger__value");
const menu = details.querySelector(".header-preference-menu"); const menu = details.querySelector(".app-select-menu");
const label = t("profile.theme", {}, "Interface theme"); const label = t("profile.theme", {}, "Interface theme");
summary.setAttribute("aria-label", label); summary.setAttribute("aria-label", label);
@@ -145,12 +149,12 @@ function renderPicker(details) {
} }
function renderAll() { function renderAll() {
document.querySelectorAll(".header-preference-picker").forEach(renderPicker); document.querySelectorAll(".app-select--header").forEach(renderPicker);
} }
function bindPicker(details) { function bindPicker(details) {
details.querySelector(".header-preference-menu")?.addEventListener("click", async event => { details.querySelector(".app-select-menu")?.addEventListener("click", async event => {
const button = event.target.closest(".header-preference-option"); const button = event.target.closest(".app-select-option");
if (!(button instanceof HTMLButtonElement)) return; if (!(button instanceof HTMLButtonElement)) return;
const next = button.dataset.value || ""; const next = button.dataset.value || "";
const kind = details.dataset.preferenceKind; const kind = details.dataset.preferenceKind;
@@ -209,7 +213,7 @@ export function initHeaderPreferences() {
document.addEventListener("pointerdown", event => { document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null; const target = event.target instanceof Element ? event.target : null;
if (!target?.closest(".header-preference-picker")) closeOtherPickers(); if (!target?.closest(".app-select--header")) closeOtherPickers();
}, { passive: true }); }, { passive: true });
document.addEventListener("keydown", event => { document.addEventListener("keydown", event => {
if (event.key === "Escape") closeOtherPickers(); if (event.key === "Escape") closeOtherPickers();
+314
View File
@@ -0,0 +1,314 @@
/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const states = new WeakMap();
let initialized = false;
let openState = null;
let observer = null;
function selectLabel(select) {
const direct = select.getAttribute("aria-label");
if (direct) return direct.trim();
if (select.id) {
const label = document.querySelector(`label[for="${CSS.escape(select.id)}"]`);
const text = label?.textContent?.trim();
if (text) return text;
}
const label = select.closest("label");
if (label) {
const clone = label.cloneNode(true);
clone.querySelectorAll("select, .app-select").forEach(node => node.remove());
const text = clone.textContent?.trim();
if (text) return text;
}
return select.name || select.id || "Select option";
}
function optionEntries(select) {
return [...select.options].map((option, index) => ({ option, index }));
}
function closeSelect(state, { focusTrigger = false } = {}) {
if (!state?.open) return;
state.open = false;
state.wrapper.classList.remove("is-open");
state.trigger.setAttribute("aria-expanded", "false");
state.menu.hidden = true;
if (openState === state) openState = null;
if (focusTrigger) state.trigger.focus();
}
function closeOtherSelects(except = null) {
if (openState && openState !== except) closeSelect(openState);
document.querySelectorAll(".app-select--header[open]").forEach(details => {
if (details !== except?.wrapper) details.open = false;
});
}
function menuRoot(select) {
return select.closest("dialog") || document.body;
}
function positionMenu(state) {
if (!state.open || state.menu.hidden || !state.trigger.isConnected) return;
const rect = state.trigger.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const gap = 6;
const edge = 8;
state.menu.style.minWidth = `${Math.ceil(rect.width)}px`;
state.menu.style.maxWidth = `${Math.max(180, viewportWidth - edge * 2)}px`;
state.menu.style.left = `${Math.max(edge, Math.min(rect.left, viewportWidth - state.menu.offsetWidth - edge))}px`;
const menuHeight = state.menu.offsetHeight;
const roomBelow = viewportHeight - rect.bottom - edge;
const roomAbove = rect.top - edge;
const openAbove = roomBelow < Math.min(menuHeight, 220) && roomAbove > roomBelow;
const top = openAbove
? Math.max(edge, rect.top - menuHeight - gap)
: Math.min(viewportHeight - menuHeight - edge, rect.bottom + gap);
state.menu.style.top = `${Math.max(edge, top)}px`;
}
function focusOption(state, direction = 1) {
const buttons = [...state.menu.querySelectorAll(".app-select-option:not(:disabled)")];
if (!buttons.length) return;
const current = document.activeElement;
const currentIndex = buttons.indexOf(current);
const selectedIndex = buttons.findIndex(button => button.getAttribute("aria-selected") === "true");
const base = currentIndex >= 0 ? currentIndex : selectedIndex;
const next = base < 0
? (direction > 0 ? 0 : buttons.length - 1)
: (base + direction + buttons.length) % buttons.length;
buttons[next].focus();
}
function renderOptions(state) {
const { select, menu } = state;
const fragment = document.createDocumentFragment();
for (const { option, index } of optionEntries(select)) {
const button = document.createElement("button");
button.type = "button";
button.className = "app-select-option";
button.dataset.optionIndex = String(index);
button.setAttribute("role", "option");
button.setAttribute("aria-selected", option.selected ? "true" : "false");
button.disabled = option.disabled || Boolean(option.parentElement?.disabled);
const check = document.createElement("span");
check.className = "app-select-option__check";
check.setAttribute("aria-hidden", "true");
check.textContent = option.selected ? "✓" : "";
const copy = document.createElement("span");
copy.className = "app-select-option__copy";
copy.textContent = option.label || option.textContent || option.value;
if (option.parentElement instanceof HTMLOptGroupElement) {
const group = document.createElement("span");
group.className = "app-select-option__group";
group.textContent = option.parentElement.label;
copy.prepend(group);
}
button.append(check, copy);
fragment.append(button);
}
menu.replaceChildren(fragment);
}
function syncSelect(state) {
const { select, trigger, value, menu, wrapper } = state;
const selected = select.selectedOptions[0] || select.options[select.selectedIndex] || select.options[0];
value.textContent = selected?.label || selected?.textContent || selected?.value || "";
trigger.disabled = select.disabled;
trigger.setAttribute("aria-disabled", select.disabled ? "true" : "false");
trigger.setAttribute("aria-label", `${selectLabel(select)}: ${value.textContent}`);
wrapper.classList.toggle("is-disabled", select.disabled);
renderOptions(state);
if (state.open) requestAnimationFrame(() => positionMenu(state));
}
function openSelect(state, { focusSelected = false } = {}) {
if (state.select.disabled || state.open) return;
closeOtherSelects(state);
state.open = true;
openState = state;
state.wrapper.classList.add("is-open");
state.trigger.setAttribute("aria-expanded", "true");
state.menu.hidden = false;
syncSelect(state);
positionMenu(state);
if (focusSelected) {
const selected = state.menu.querySelector('.app-select-option[aria-selected="true"]:not(:disabled)')
|| state.menu.querySelector(".app-select-option:not(:disabled)");
selected?.focus();
}
}
function toggleSelect(state) {
if (state.open) closeSelect(state);
else openSelect(state);
}
function enhanceSelect(select) {
if (!(select instanceof HTMLSelectElement)) return;
if (select.dataset.appSelectEnhanced === "true") return;
if (select.multiple || Number(select.size || 0) > 1 || select.dataset.appSelect === "native") return;
const wrapper = document.createElement("span");
wrapper.className = "app-select app-select--field";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "app-select-trigger";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
const value = document.createElement("span");
value.className = "app-select-trigger__value";
const chevron = document.createElement("span");
chevron.className = "app-select-trigger__chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.textContent = "▾";
const menu = document.createElement("div");
menu.className = "app-select-menu app-select-menu--portal";
menu.setAttribute("role", "listbox");
menu.hidden = true;
trigger.append(value, chevron);
wrapper.append(trigger);
const rect = select.getBoundingClientRect();
if (rect.width) wrapper.style.minWidth = `${Math.ceil(rect.width)}px`;
select.insertAdjacentElement("afterend", wrapper);
menuRoot(select).append(menu);
select.dataset.appSelectEnhanced = "true";
select.classList.add("app-select-native");
select.tabIndex = -1;
const state = { select, wrapper, trigger, value, menu, open: false };
states.set(select, state);
trigger.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
toggleSelect(state);
});
trigger.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
openSelect(state, { focusSelected: true });
if (event.key === "ArrowUp") focusOption(state, -1);
}
});
menu.addEventListener("click", event => {
const button = event.target.closest(".app-select-option");
if (!(button instanceof HTMLButtonElement) || button.disabled) return;
event.preventDefault();
event.stopPropagation();
const index = Number(button.dataset.optionIndex);
if (!Number.isInteger(index) || !select.options[index]) return;
const changed = select.selectedIndex !== index;
select.selectedIndex = index;
syncSelect(state);
closeSelect(state, { focusTrigger: true });
if (changed) {
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
menu.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
focusOption(state, event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const buttons = [...menu.querySelectorAll(".app-select-option:not(:disabled)")];
(event.key === "Home" ? buttons[0] : buttons.at(-1))?.focus();
return;
}
if (event.key === "Escape" || event.key === "Tab") {
closeSelect(state, { focusTrigger: event.key === "Escape" });
}
});
select.addEventListener("change", () => syncSelect(state));
select.addEventListener("rustpad:select-sync", () => syncSelect(state));
syncSelect(state);
}
function scan(root = document) {
if (root instanceof HTMLSelectElement) enhanceSelect(root);
root.querySelectorAll?.("select").forEach(enhanceSelect);
}
function syncMutation(mutation) {
const target = mutation.target instanceof Element ? mutation.target : mutation.target?.parentElement;
const select = target?.closest?.("select");
const state = select ? states.get(select) : null;
if (state) syncSelect(state);
}
export function syncSelectControl(select) {
const state = states.get(select);
if (state) syncSelect(state);
}
export function initSelectControls() {
scan(document);
if (initialized) return;
initialized = true;
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node instanceof Element) scan(node);
});
if (mutation.type === "attributes" || mutation.target instanceof HTMLOptionElement || mutation.target instanceof HTMLOptGroupElement) syncMutation(mutation);
}
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["disabled", "aria-label", "label"],
});
document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null;
if (openState && !openState.wrapper.contains(target) && !openState.menu.contains(target)) closeSelect(openState);
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && openState) closeSelect(openState, { focusTrigger: true });
});
window.addEventListener("resize", () => {
if (openState) positionMenu(openState);
}, { passive: true });
document.addEventListener("scroll", () => {
if (openState) positionMenu(openState);
}, { capture: true, passive: true });
}
+1
View File
@@ -6,6 +6,7 @@
* Commercial or production use requires a valid paid license. * Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details. * See LICENSE file in repository root for details.
*/ */
const STORAGE_KEY = "rustpad:theme"; const STORAGE_KEY = "rustpad:theme";
const GUEST_STORAGE_KEY = "rustpad:guest-theme"; const GUEST_STORAGE_KEY = "rustpad:guest-theme";
const AUTH_STATE_KEY = "rustpad:auth-state"; const AUTH_STATE_KEY = "rustpad:auth-state";
+5 -1
View File
@@ -1,6 +1,10 @@
/* /*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl * Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed. * Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/ */
import { t } from "@rustpad/i18n"; import { t } from "@rustpad/i18n";
+1 -1
View File
@@ -45,7 +45,7 @@
<div class="notes-toolbar"> <div class="notes-toolbar">
<label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search" <label class="list-search"><span class="sr-only">Search notes</span><input id="notes-search" type="search"
placeholder="Search notes…" autocomplete="off"></label> placeholder="Search notes…" autocomplete="off"></label>
<label class="page-size-label">Per page<select id="notes-per-page"> <label class="page-size-label">Per page<select class="app-select-input" id="notes-per-page">
<option value="25">25</option> <option value="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>