diff --git a/static/js/image-upload.js b/static/js/image-upload.js new file mode 100644 index 0000000..2453ab9 --- /dev/null +++ b/static/js/image-upload.js @@ -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=`
+

Adjust image

Drag to crop, use zoom and choose output size.

+
+
+ + + +
+
+
`; + 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; +} diff --git a/static/js/note.js b/static/js/note.js index 9870591..e8327e2 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -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",'

Failed to load Mermaid.

'));}} function renderGutter(){ - const lines=editor.value.split("\n"); - owners=owners.slice(0,lines.length); - while(owners.length`
${i+1}
`).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`
${i+1}
`).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.lengthsocket?.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='

Loading…

';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 `
${escapeHtml(author)}

${snippet}

`;}).join(""):'

No history yet.

';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=`

${escapeHtml(e.message)}

`;}});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(); diff --git a/static/js/pad.js b/static/js/pad.js index 59022fe..1cd587e 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -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",'

Failed to load Mermaid.

'));}} function renderGutter(){ - const lines=editor.value.split("\n"); - owners=owners.slice(0,lines.length); - while(owners.length`
${i+1}
`).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`
${i+1}
`).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.lengthsocket?.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='

Loading…

';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 `
${escapeHtml(author)}

${snippet}

`;}).join(""):'

No history yet.

';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=`

${escapeHtml(e.message)}

`;}});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(); diff --git a/static/styles.css b/static/styles.css index 8d444ff..a75ea27 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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); }