fixes in ux

This commit is contained in:
Mateusz Gruszczyński
2026-07-20 23:38:56 +02:00
parent 9afaa3dc60
commit 4bb92a343b
4 changed files with 108 additions and 12 deletions
+64
View File
@@ -0,0 +1,64 @@
function clamp(value,min,max){return Math.min(max,Math.max(min,value));}
function stem(name){return name.replace(/\.[^.]+$/,"")||"image";}
export async function prepareImageFile(file){
if(!file.type.startsWith("image/"))return file;
const url=URL.createObjectURL(file);
const image=new Image();
image.src=url;
await image.decode();
const dialog=document.createElement("dialog");
dialog.className="image-editor-dialog";
dialog.innerHTML=`<form method="dialog" class="image-editor-panel">
<div class="image-editor-head"><div><h2>Adjust image</h2><p>Drag to crop, use zoom and choose 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-editor-controls">
<label>Crop<select data-aspect><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>Max size<select data-size><option value="1200">1200 px</option><option value="1600" selected>1600 px</option><option value="2000">2000 px</option><option value="0">Original</option></select></label>
</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>`;
document.body.append(dialog);
const canvas=dialog.querySelector("canvas"),ctx=canvas.getContext("2d"),stage=dialog.querySelector(".image-crop-stage"),zoomInput=dialog.querySelector("[data-zoom]"),aspectSelect=dialog.querySelector("[data-aspect]"),sizeSelect=dialog.querySelector("[data-size]");
let offsetX=0,offsetY=0,dragging=false,lastX=0,lastY=0,accepted=false;
function cropBox(){
const rect=stage.getBoundingClientRect();
let width=Math.max(280,rect.width),height=Math.min(520,Math.max(260,rect.height));
const aspect=aspectSelect.value==="free"?width/height:Number(aspectSelect.value);
if(width/height>aspect)width=height*aspect;else height=width/aspect;
return {width:Math.round(width),height:Math.round(height)};
}
function draw(){
const box=cropBox(),dpr=Math.min(devicePixelRatio||1,2);
canvas.width=Math.round(box.width*dpr);canvas.height=Math.round(box.height*dpr);canvas.style.width=`${box.width}px`;canvas.style.height=`${box.height}px`;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,box.width,box.height);
const base=Math.max(box.width/image.naturalWidth,box.height/image.naturalHeight),scale=base*Number(zoomInput.value);
const drawW=image.naturalWidth*scale,drawH=image.naturalHeight*scale;
const maxX=Math.max(0,(drawW-box.width)/2),maxY=Math.max(0,(drawH-box.height)/2);
offsetX=clamp(offsetX,-maxX,maxX);offsetY=clamp(offsetY,-maxY,maxY);
ctx.drawImage(image,(box.width-drawW)/2+offsetX,(box.height-drawH)/2+offsetY,drawW,drawH);
}
function point(event){const p=event.touches?.[0]||event;return {x:p.clientX,y:p.clientY};}
canvas.addEventListener("pointerdown",event=>{dragging=true;canvas.setPointerCapture(event.pointerId);({x:lastX,y:lastY}=point(event));});
canvas.addEventListener("pointermove",event=>{if(!dragging)return;const p=point(event);offsetX+=p.x-lastX;offsetY+=p.y-lastY;lastX=p.x;lastY=p.y;draw();});
canvas.addEventListener("pointerup",()=>dragging=false);canvas.addEventListener("pointercancel",()=>dragging=false);
zoomInput.addEventListener("input",draw);aspectSelect.addEventListener("change",()=>{offsetX=0;offsetY=0;draw();});window.addEventListener("resize",draw,{signal:(()=>{const c=new AbortController();dialog.addEventListener("close",()=>c.abort(),{once:true});return c.signal;})()});
const result=new Promise(resolve=>{
dialog.addEventListener("close",()=>{URL.revokeObjectURL(url);dialog.remove();resolve(accepted);},{once:true});
dialog.querySelector("[data-apply]").addEventListener("click",async()=>{
const box=cropBox(),maxSize=Number(sizeSelect.value),ratio=Math.min(1,maxSize?maxSize/Math.max(box.width,box.height):1),out=document.createElement("canvas");
out.width=Math.max(1,Math.round(box.width*ratio));out.height=Math.max(1,Math.round(box.height*ratio));out.getContext("2d").drawImage(canvas,0,0,out.width,out.height);
const mime=file.type==="image/png"?"image/png":"image/jpeg";
const blob=await new Promise(r=>out.toBlob(r,mime,mime==="image/jpeg"?.88:undefined));
const ext=mime==="image/png"?"png":"jpg";
accepted=new File([blob],`${stem(file.name)}-edited.${ext}`,{type:mime,lastModified:Date.now()});
dialog.close();
});
});
dialog.showModal();requestAnimationFrame(draw);
return result;
}
+9 -6
View File
@@ -2,6 +2,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { applyFormat } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session";
import { NoteSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -21,11 +22,13 @@ function setStatus(kind,text){document.querySelector("#status-dot").className=`s
function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;}
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
function renderGutter(){
const lines=editor.value.split("\n");
owners=owners.slice(0,lines.length);
while(owners.length<lines.length)owners.push(owners.at(-1)||nickname||"");
gutter.innerHTML=lines.map((_,i)=>`<div>${i+1}</div>`).join("");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24;
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount});
owners=owners.slice(0,lineCount);
while(owners.length<lineCount)owners.push(owners.at(-1)||nickname||"");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height",`${lineHeight}px`);
ownerLabels.innerHTML=lines.map((_,i)=>{
const owner=owners[i]||"";
@@ -49,7 +52,7 @@ document.querySelector("#publish-page").addEventListener("click",async()=>{try{c
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";connect();});
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{const file=e.target.files[0];if(!file)return;const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);});
window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);});
initialize();
+9 -6
View File
@@ -2,6 +2,7 @@ import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { applyFormat } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js";
import { getNickname, setNickname } from "@rustpad/session";
import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -21,11 +22,13 @@ function setStatus(kind,text){document.querySelector("#status-dot").className=`s
function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;}
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
function renderGutter(){
const lines=editor.value.split("\n");
owners=owners.slice(0,lines.length);
while(owners.length<lines.length)owners.push(owners.at(-1)||nickname||"");
gutter.innerHTML=lines.map((_,i)=>`<div>${i+1}</div>`).join("");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24;
const lineCount=Math.max(1,(editor.value.match(/\n/g)||[]).length+1);
const lines=Array.from({length:lineCount});
owners=owners.slice(0,lineCount);
while(owners.length<lineCount)owners.push(owners.at(-1)||nickname||"");
const style=getComputedStyle(editor), lineHeight=parseFloat(style.lineHeight)||29, paddingTop=parseFloat(style.paddingTop)||24, paddingBottom=parseFloat(style.paddingBottom)||24;
gutter.style.paddingTop=`${paddingTop}px`;gutter.style.paddingBottom=`${paddingBottom}px`;gutter.style.lineHeight=`${lineHeight}px`;
gutter.innerHTML=lines.map((_,i)=>`<div style="height:${lineHeight}px">${i+1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height",`${lineHeight}px`);
ownerLabels.innerHTML=lines.map((_,i)=>{
const owner=owners[i]||"";
@@ -49,5 +52,5 @@ document.querySelector("#publish-page").addEventListener("click",async()=>{try{c
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();});
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{const file=e.target.files[0];if(!file)return;const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{let file=e.target.files[0];if(!file)return;if(file.type.startsWith("image/")){file=await prepareImageFile(file);if(!file){e.target.value="";return;}}const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
initialize();
+26
View File
@@ -293,3 +293,29 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
@media (max-width: 900px) { .editor-controls { order: 3; width: 100%; } .editor-toolbar { flex-wrap: wrap; } }
.editor-shell textarea { white-space: pre; overflow: auto; overflow-wrap: normal; word-break: normal; }
/* Keep line numbers exactly aligned with the logical text lines. */
.line-gutter { box-sizing: border-box; font-size: var(--editor-font-size, 18px); line-height: normal; }
.line-gutter div { box-sizing: border-box; padding-right: 8px; line-height: inherit; }
.compact-editor .line-gutter { font-size: calc(var(--editor-font-size, 18px) - 2px); }
/* Image crop/resize dialog. */
.image-editor-dialog { width: min(920px, calc(100% - 28px)); max-width: none; padding: 0; border: 1px solid var(--border); border-radius: 14px; background: #11151b; color: #eef2f7; }
.image-editor-dialog::backdrop { background: rgba(4,6,9,.78); backdrop-filter: blur(4px); }
.image-editor-panel { display: grid; gap: 16px; padding: 18px; }
.image-editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.image-editor-head h2 { margin: 0; }
.image-editor-head p { margin: 5px 0 0; color: var(--muted); }
.image-crop-stage { display: grid; place-items: center; min-height: 360px; max-height: 58vh; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: #080b10; }
.image-crop-stage canvas { display: block; max-width: 100%; max-height: 58vh; cursor: grab; touch-action: none; }
.image-crop-stage canvas:active { cursor: grabbing; }
.image-editor-controls { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }
.image-editor-controls label { display: grid; gap: 6px; color: var(--muted); font-size: .78rem; }
.image-editor-controls select,.image-editor-controls input { width: 100%; }
.image-editor-actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 680px) { .image-editor-controls { grid-template-columns: 1fr; } .image-crop-stage { min-height: 260px; } }
/* Wider published page workspace. */
.public-header { padding-inline: max(20px, calc((100vw - 1180px) / 2)); }
.public-document { width: min(1180px, calc(100% - 32px)); }
.public-content { padding: clamp(24px, 4vw, 52px); }