From 0aa52e9ba6386a37616faff97353afeacc42aa05 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 22 Jul 2026 19:34:42 +0530 Subject: [PATCH 01/10] feat(editor): improve image and file drag and drop --- CHANGELOG.md | 4 + README.md | 3 +- desktop-app/resources/index.html | 10 +- desktop-app/resources/js/script.js | 204 ++++++++++++++++++++++++++++- desktop-app/resources/styles.css | 67 +++++----- index.html | 10 +- script.js | 204 ++++++++++++++++++++++++++++- styles.css | 67 +++++----- wiki/Usage-Guide.md | 6 +- 9 files changed, 490 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddb5ac8..1f980d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable code changes to **Markdown Viewer** are documented here. Non-code commits (documentation, planning, README-only updates) are excluded. +## Unreleased + +- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, and added clipboard-paste and drag-and-drop image insertion. + ## v3.9.3 - **Description:** Redesigned the Markdown Viewer workspace around a professional document Explorer, streamlined toolbar, responsive navigation, consistent icon system, and complete interface localization. diff --git a/README.md b/README.md index 3ad12812..8a55c235 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ For the full feature list, details, limitations, and privacy notes, see the [fea ## Markdown Editing and Live Preview - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. -- Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, and drag-and-drop moves. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. +- Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, precise drag-and-drop moves, hover-to-expand folders, and edge auto-scrolling. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. +- Paste images from the clipboard or drop image files into the app to insert Markdown image syntax at the editor cursor. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index fbaf4faa..db07d531 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1204,12 +1204,12 @@

Markdown Viewer

- - diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 572e47ae..169e8876 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -570,6 +570,7 @@ document.addEventListener("DOMContentLoaded", async function () { const GLOBAL_STATE_KEY = 'markdownViewerGlobalState'; let referenceCounter = 1; const imageObjectUrls = new Set(); + const MAX_LOCAL_IMAGE_BYTES = 25 * 1024 * 1024; const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -2501,6 +2502,10 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; let draggedSidebarDocumentId = null; + let documentTreeDragExpandTimer = null; + let documentTreeDragExpandRow = null; + let documentTreeAutoScrollFrame = null; + let documentTreeAutoScrollSpeed = 0; let secretWorkspaceKey = null; let secretWorkspaceSalt = null; let secretWorkspaceIterations = SECRET_KDF_ITERATIONS; @@ -3932,6 +3937,88 @@ document.addEventListener("DOMContentLoaded", async function () { }); } + function clearDocumentTreeDragExpansion() { + if (documentTreeDragExpandTimer) clearTimeout(documentTreeDragExpandTimer); + documentTreeDragExpandTimer = null; + documentTreeDragExpandRow = null; + } + + function stopDocumentTreeAutoScroll() { + documentTreeAutoScrollSpeed = 0; + if (documentTreeAutoScrollFrame) cancelAnimationFrame(documentTreeAutoScrollFrame); + documentTreeAutoScrollFrame = null; + } + + function runDocumentTreeAutoScroll() { + const tree = document.getElementById('document-tree'); + if (!tree || !documentTreeAutoScrollSpeed) { + documentTreeAutoScrollFrame = null; + return; + } + tree.scrollTop += documentTreeAutoScrollSpeed; + documentTreeAutoScrollFrame = requestAnimationFrame(runDocumentTreeAutoScroll); + } + + function updateDocumentTreeAutoScroll(event) { + const tree = document.getElementById('document-tree'); + if (!tree || !event || !Number.isFinite(event.clientY)) return; + const rect = tree.getBoundingClientRect(); + const edgeSize = Math.min(56, Math.max(32, rect.height * 0.16)); + let speed = 0; + if (event.clientY < rect.top + edgeSize) { + speed = -Math.max(3, Math.round((rect.top + edgeSize - event.clientY) / 4)); + } else if (event.clientY > rect.bottom - edgeSize) { + speed = Math.max(3, Math.round((event.clientY - (rect.bottom - edgeSize)) / 4)); + } + documentTreeAutoScrollSpeed = Math.max(-14, Math.min(14, speed)); + if (documentTreeAutoScrollSpeed && !documentTreeAutoScrollFrame) { + documentTreeAutoScrollFrame = requestAnimationFrame(runDocumentTreeAutoScroll); + } else if (!documentTreeAutoScrollSpeed) { + stopDocumentTreeAutoScroll(); + } + } + + function expandDocumentTreeRowForDrag(row, location) { + if (!row || !location || row.getAttribute('aria-expanded') !== 'false') return; + const item = location.folderId ? getFolderById(location.folderId) : getWorkspaceById(location.workspaceId); + if (!item || (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked())) return; + item.expanded = true; + saveDocumentOrganization(); + row.setAttribute('aria-expanded', 'true'); + const toggle = row.querySelector('.document-tree-toggle'); + if (toggle) { + toggle.setAttribute('aria-label', 'Collapse ' + (item.name || 'folder')); + const toggleIcon = toggle.querySelector('i'); + if (toggleIcon) toggleIcon.className = 'lucide lucide-chevron-down'; + } + const folderIcon = row.querySelector('.document-tree-main > i:first-child'); + if (folderIcon && location.workspaceId !== SECRET_WORKSPACE_ID) folderIcon.className = 'lucide lucide-folder-open'; + const group = row.nextElementSibling; + if (group && group.classList.contains('document-tree-group')) group.hidden = false; + announceToScreenReader((item.name || 'Folder') + ' expanded.'); + } + + function scheduleDocumentTreeDragExpansion(row, location) { + if (!row || row.getAttribute('aria-expanded') !== 'false') { + clearDocumentTreeDragExpansion(); + return; + } + if (documentTreeDragExpandRow === row && documentTreeDragExpandTimer) return; + clearDocumentTreeDragExpansion(); + documentTreeDragExpandRow = row; + documentTreeDragExpandTimer = setTimeout(function() { + documentTreeDragExpandTimer = null; + documentTreeDragExpandRow = null; + expandDocumentTreeRowForDrag(row, location); + }, 650); + } + + function resetDocumentTreeDragFeedback() { + clearDocumentDropTargets(); + clearDocumentTreeDragExpansion(); + stopDocumentTreeAutoScroll(); + } + function attachDocumentDropTarget(row, location) { if (!row || !location) return; row.setAttribute('data-drop-workspace-id', location.workspaceId); @@ -3944,20 +4031,23 @@ document.addEventListener("DOMContentLoaded", async function () { const hasFiles = transferTypes.includes('Files') || Boolean(event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length); if (!draggedSidebarDocumentId && !hasFiles) return; event.preventDefault(); - event.stopPropagation(); if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentId ? 'move' : 'copy'; clearDocumentDropTargets(); row.classList.add('is-drop-target'); + scheduleDocumentTreeDragExpansion(row, location); }); row.addEventListener('dragleave', function(event) { - if (!event.relatedTarget || !row.contains(event.relatedTarget)) row.classList.remove('is-drop-target'); + if (!event.relatedTarget || !row.contains(event.relatedTarget)) { + row.classList.remove('is-drop-target'); + if (documentTreeDragExpandRow === row) clearDocumentTreeDragExpansion(); + } }); row.addEventListener('drop', function(event) { event.preventDefault(); event.stopPropagation(); - row.classList.remove('is-drop-target'); + resetDocumentTreeDragFeedback(); const files = event.dataTransfer && event.dataTransfer.files; if (files && files.length) { dragDepth = 0; @@ -3966,7 +4056,7 @@ document.addEventListener("DOMContentLoaded", async function () { dragOverlay.setAttribute('aria-hidden', 'true'); } const importFiles = function() { - importMarkdownFiles(files, { location: location }); + handleIncomingDroppedFiles(files, { location: location }); }; if (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(importFiles); @@ -4087,7 +4177,7 @@ document.addEventListener("DOMContentLoaded", async function () { row.addEventListener('dragend', function() { draggedSidebarDocumentId = null; row.classList.remove('is-dragging'); - clearDocumentDropTargets(); + resetDocumentTreeDragFeedback(); }); } if (options.dropLocation) attachDocumentDropTarget(row, options.dropLocation); @@ -4559,6 +4649,11 @@ document.addEventListener("DOMContentLoaded", async function () { tree.addEventListener('click', function(event) { if (!event.target.closest('.document-tree-row')) clearDocumentTreeSelection({ announce: false }); }); + tree.addEventListener('dragover', updateDocumentTreeAutoScroll); + tree.addEventListener('dragleave', function(event) { + if (!event.relatedTarget || !tree.contains(event.relatedTarget)) stopDocumentTreeAutoScroll(); + }); + tree.addEventListener('drop', stopDocumentTreeAutoScroll); } if (resizer && sidebar) { @@ -10327,6 +10422,54 @@ ${selector} .arrowheadPath { lastInputType = 'programmatic'; } + function isImageFile(file) { + if (!file) return false; + if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) return true; + return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(file.name || ''); + } + + function getLocalImageAltText(file, source, index, total) { + let label = source === 'clipboard' + ? 'Pasted image' + : String(file && file.name ? file.name : 'Dropped image').replace(/\.[^.]+$/, ''); + label = label.replace(/[\[\]\r\n]+/g, ' ').trim() || 'Image'; + return total > 1 ? label + ' ' + (index + 1) : label; + } + + function createLocalImageMarkdown(file, altText) { + const objectUrl = URL.createObjectURL(file); + imageObjectUrls.add(objectUrl); + return '![' + altText + '](' + objectUrl + ')'; + } + + function insertImageFilesIntoEditor(fileList, options) { + const settings = options || {}; + const imageFiles = Array.from(fileList || []).filter(isImageFile); + if (!imageFiles.length) return 0; + if (!hasActiveOpenDocument() || !canMutateEditor()) { + announceToScreenReader(getEditorReadOnlyMessage()); + return 0; + } + + const acceptedFiles = imageFiles.filter(function(file) { + return !Number.isFinite(file.size) || file.size <= MAX_LOCAL_IMAGE_BYTES; + }); + if (acceptedFiles.length !== imageFiles.length) { + alert('Images larger than 25MB cannot be inserted.'); + } + if (!acceptedFiles.length) return 0; + + const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; + const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; + const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; + const markdown = acceptedFiles.map(function(file, index) { + return createLocalImageMarkdown(file, getLocalImageAltText(file, source, index, acceptedFiles.length)); + }).join('\n\n'); + replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted.'); + return acceptedFiles.length; + } + function wrapEditorSelection(prefix, suffix, placeholder) { const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; @@ -15350,6 +15493,26 @@ ${selector} .arrowheadPath { }); }); + markdownEditor.addEventListener('paste', function(event) { + const clipboard = event.clipboardData; + if (!clipboard) return; + const itemFiles = Array.from(clipboard.items || []).map(function(item) { + return item.kind === 'file' ? item.getAsFile() : null; + }).filter(Boolean); + const imageFiles = (itemFiles.length ? itemFiles : Array.from(clipboard.files || [])).filter(isImageFile); + if (!imageFiles.length) return; + event.preventDefault(); + if (!canMutateEditor()) { + announceToScreenReader(getEditorReadOnlyMessage()); + return; + } + insertImageFilesIntoEditor(imageFiles, { + source: 'clipboard', + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + }); + markdownEditor.addEventListener('keydown', updateLastCursor); markdownEditor.addEventListener('keyup', updateLastCursor); markdownEditor.addEventListener('mousedown', updateLastCursor); @@ -20193,6 +20356,7 @@ ${selector} .arrowheadPath { dragDepth = 0; dragOverlay.classList.remove("active"); dragOverlay.setAttribute("aria-hidden", "true"); + resetDocumentTreeDragFeedback(); handleDrop(e); }, false); @@ -20200,8 +20364,36 @@ ${selector} .arrowheadPath { const dt = e.dataTransfer; const files = dt && dt.files; if (files && files.length) { - await importMarkdownFiles(files); + await handleIncomingDroppedFiles(files, { + location: { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null } + }); + } + } + + async function handleIncomingDroppedFiles(fileList, options) { + const settings = options || {}; + const files = Array.from(fileList || []); + const imageFiles = files.filter(isImageFile); + const markdownFiles = files.filter(isMarkdownFile); + let handledCount = 0; + + if (imageFiles.length) { + const insertedCount = insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); + handledCount += insertedCount; + if (!insertedCount && !markdownFiles.length) { + alert('Open an editable Markdown file before inserting images.'); + } + } + if (markdownFiles.length) { + handledCount += await importMarkdownFiles(markdownFiles, { + location: settings.location || { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }, + showInvalidAlert: false + }); + } + if (!imageFiles.length && !markdownFiles.length) { + alert('Drop Markdown files or images to add them.'); } + return handledCount; } document.addEventListener("keydown", function (e) { diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index d5678526..a332fcb1 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -31,6 +31,7 @@ --segmented-surface: #ffffff; --segmented-shadow: 0 1px 2px rgba(31, 35, 40, 0.12), 0 0 0 0.5px rgba(31, 35, 40, 0.06); --layer-menu: 1100; + --layer-notice: 1200; --button-bg: #f6f8fa; --button-hover: #e1e4e8; --button-active: #d1d5da; @@ -2206,61 +2207,67 @@ body.document-sidebar-collapsed .document-sidebar { display: none; } -/* Drag overlay: full-screen drop target shown when user drags a file over the window */ +/* Compact drag notice that leaves the Explorer and editor available as drop targets. */ .drag-overlay { display: none; position: fixed; - inset: 0; - z-index: 9999; - background-color: rgba(0, 0, 0, 0.45); + inset: auto 20px 20px auto; + width: min(360px, calc(100vw - 40px)); + z-index: var(--layer-notice); pointer-events: none; - align-items: center; - justify-content: center; } .drag-overlay.active { - display: flex; - pointer-events: auto; + display: block; + pointer-events: none; } .drag-overlay-inner { - border: 3px dashed var(--accent-color); - border-radius: 12px; - padding: 48px 64px; - text-align: center; - color: #ffffff; - background-color: rgba(3, 102, 214, 0.15); - animation: overlayPulse 1.4s ease-in-out infinite; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent-color) 55%, var(--border-color)); + border-radius: 9px; + color: var(--text-color); + background: color-mix(in srgb, var(--header-bg) 94%, transparent); + box-shadow: 0 10px 30px rgba(31, 35, 40, 0.2); + backdrop-filter: blur(10px); + animation: dragNoticeIn 0.18s ease-out; } .drag-overlay-icon { - display: block; - font-size: 3rem; - margin-bottom: 12px; + grid-row: 1 / span 2; + justify-self: center; + font-size: 21px; color: var(--accent-color); } .drag-overlay-text { - font-size: 1.4rem; + margin: 0; + font-size: 13px; font-weight: 600; - margin-bottom: 4px; + line-height: 1.35; } .drag-overlay-sub { - font-size: 0.85rem; - opacity: 0.75; - margin-bottom: 0; + margin: 1px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; } -@keyframes overlayPulse { - - 0%, - 100% { - transform: scale(1); +@keyframes dragNoticeIn { + from { + opacity: 0; + transform: translateY(6px); } - 50% { - transform: scale(1.015); + to { + opacity: 1; + transform: translateY(0); } } diff --git a/index.html b/index.html index d80b84f3..ddd508e6 100644 --- a/index.html +++ b/index.html @@ -1297,12 +1297,12 @@

Markdown Viewer

- - diff --git a/script.js b/script.js index 572e47ae..169e8876 100644 --- a/script.js +++ b/script.js @@ -570,6 +570,7 @@ document.addEventListener("DOMContentLoaded", async function () { const GLOBAL_STATE_KEY = 'markdownViewerGlobalState'; let referenceCounter = 1; const imageObjectUrls = new Set(); + const MAX_LOCAL_IMAGE_BYTES = 25 * 1024 * 1024; const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -2501,6 +2502,10 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; let draggedSidebarDocumentId = null; + let documentTreeDragExpandTimer = null; + let documentTreeDragExpandRow = null; + let documentTreeAutoScrollFrame = null; + let documentTreeAutoScrollSpeed = 0; let secretWorkspaceKey = null; let secretWorkspaceSalt = null; let secretWorkspaceIterations = SECRET_KDF_ITERATIONS; @@ -3932,6 +3937,88 @@ document.addEventListener("DOMContentLoaded", async function () { }); } + function clearDocumentTreeDragExpansion() { + if (documentTreeDragExpandTimer) clearTimeout(documentTreeDragExpandTimer); + documentTreeDragExpandTimer = null; + documentTreeDragExpandRow = null; + } + + function stopDocumentTreeAutoScroll() { + documentTreeAutoScrollSpeed = 0; + if (documentTreeAutoScrollFrame) cancelAnimationFrame(documentTreeAutoScrollFrame); + documentTreeAutoScrollFrame = null; + } + + function runDocumentTreeAutoScroll() { + const tree = document.getElementById('document-tree'); + if (!tree || !documentTreeAutoScrollSpeed) { + documentTreeAutoScrollFrame = null; + return; + } + tree.scrollTop += documentTreeAutoScrollSpeed; + documentTreeAutoScrollFrame = requestAnimationFrame(runDocumentTreeAutoScroll); + } + + function updateDocumentTreeAutoScroll(event) { + const tree = document.getElementById('document-tree'); + if (!tree || !event || !Number.isFinite(event.clientY)) return; + const rect = tree.getBoundingClientRect(); + const edgeSize = Math.min(56, Math.max(32, rect.height * 0.16)); + let speed = 0; + if (event.clientY < rect.top + edgeSize) { + speed = -Math.max(3, Math.round((rect.top + edgeSize - event.clientY) / 4)); + } else if (event.clientY > rect.bottom - edgeSize) { + speed = Math.max(3, Math.round((event.clientY - (rect.bottom - edgeSize)) / 4)); + } + documentTreeAutoScrollSpeed = Math.max(-14, Math.min(14, speed)); + if (documentTreeAutoScrollSpeed && !documentTreeAutoScrollFrame) { + documentTreeAutoScrollFrame = requestAnimationFrame(runDocumentTreeAutoScroll); + } else if (!documentTreeAutoScrollSpeed) { + stopDocumentTreeAutoScroll(); + } + } + + function expandDocumentTreeRowForDrag(row, location) { + if (!row || !location || row.getAttribute('aria-expanded') !== 'false') return; + const item = location.folderId ? getFolderById(location.folderId) : getWorkspaceById(location.workspaceId); + if (!item || (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked())) return; + item.expanded = true; + saveDocumentOrganization(); + row.setAttribute('aria-expanded', 'true'); + const toggle = row.querySelector('.document-tree-toggle'); + if (toggle) { + toggle.setAttribute('aria-label', 'Collapse ' + (item.name || 'folder')); + const toggleIcon = toggle.querySelector('i'); + if (toggleIcon) toggleIcon.className = 'lucide lucide-chevron-down'; + } + const folderIcon = row.querySelector('.document-tree-main > i:first-child'); + if (folderIcon && location.workspaceId !== SECRET_WORKSPACE_ID) folderIcon.className = 'lucide lucide-folder-open'; + const group = row.nextElementSibling; + if (group && group.classList.contains('document-tree-group')) group.hidden = false; + announceToScreenReader((item.name || 'Folder') + ' expanded.'); + } + + function scheduleDocumentTreeDragExpansion(row, location) { + if (!row || row.getAttribute('aria-expanded') !== 'false') { + clearDocumentTreeDragExpansion(); + return; + } + if (documentTreeDragExpandRow === row && documentTreeDragExpandTimer) return; + clearDocumentTreeDragExpansion(); + documentTreeDragExpandRow = row; + documentTreeDragExpandTimer = setTimeout(function() { + documentTreeDragExpandTimer = null; + documentTreeDragExpandRow = null; + expandDocumentTreeRowForDrag(row, location); + }, 650); + } + + function resetDocumentTreeDragFeedback() { + clearDocumentDropTargets(); + clearDocumentTreeDragExpansion(); + stopDocumentTreeAutoScroll(); + } + function attachDocumentDropTarget(row, location) { if (!row || !location) return; row.setAttribute('data-drop-workspace-id', location.workspaceId); @@ -3944,20 +4031,23 @@ document.addEventListener("DOMContentLoaded", async function () { const hasFiles = transferTypes.includes('Files') || Boolean(event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length); if (!draggedSidebarDocumentId && !hasFiles) return; event.preventDefault(); - event.stopPropagation(); if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentId ? 'move' : 'copy'; clearDocumentDropTargets(); row.classList.add('is-drop-target'); + scheduleDocumentTreeDragExpansion(row, location); }); row.addEventListener('dragleave', function(event) { - if (!event.relatedTarget || !row.contains(event.relatedTarget)) row.classList.remove('is-drop-target'); + if (!event.relatedTarget || !row.contains(event.relatedTarget)) { + row.classList.remove('is-drop-target'); + if (documentTreeDragExpandRow === row) clearDocumentTreeDragExpansion(); + } }); row.addEventListener('drop', function(event) { event.preventDefault(); event.stopPropagation(); - row.classList.remove('is-drop-target'); + resetDocumentTreeDragFeedback(); const files = event.dataTransfer && event.dataTransfer.files; if (files && files.length) { dragDepth = 0; @@ -3966,7 +4056,7 @@ document.addEventListener("DOMContentLoaded", async function () { dragOverlay.setAttribute('aria-hidden', 'true'); } const importFiles = function() { - importMarkdownFiles(files, { location: location }); + handleIncomingDroppedFiles(files, { location: location }); }; if (location.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) { withUnlockedSecretWorkspace(importFiles); @@ -4087,7 +4177,7 @@ document.addEventListener("DOMContentLoaded", async function () { row.addEventListener('dragend', function() { draggedSidebarDocumentId = null; row.classList.remove('is-dragging'); - clearDocumentDropTargets(); + resetDocumentTreeDragFeedback(); }); } if (options.dropLocation) attachDocumentDropTarget(row, options.dropLocation); @@ -4559,6 +4649,11 @@ document.addEventListener("DOMContentLoaded", async function () { tree.addEventListener('click', function(event) { if (!event.target.closest('.document-tree-row')) clearDocumentTreeSelection({ announce: false }); }); + tree.addEventListener('dragover', updateDocumentTreeAutoScroll); + tree.addEventListener('dragleave', function(event) { + if (!event.relatedTarget || !tree.contains(event.relatedTarget)) stopDocumentTreeAutoScroll(); + }); + tree.addEventListener('drop', stopDocumentTreeAutoScroll); } if (resizer && sidebar) { @@ -10327,6 +10422,54 @@ ${selector} .arrowheadPath { lastInputType = 'programmatic'; } + function isImageFile(file) { + if (!file) return false; + if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) return true; + return /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(file.name || ''); + } + + function getLocalImageAltText(file, source, index, total) { + let label = source === 'clipboard' + ? 'Pasted image' + : String(file && file.name ? file.name : 'Dropped image').replace(/\.[^.]+$/, ''); + label = label.replace(/[\[\]\r\n]+/g, ' ').trim() || 'Image'; + return total > 1 ? label + ' ' + (index + 1) : label; + } + + function createLocalImageMarkdown(file, altText) { + const objectUrl = URL.createObjectURL(file); + imageObjectUrls.add(objectUrl); + return '![' + altText + '](' + objectUrl + ')'; + } + + function insertImageFilesIntoEditor(fileList, options) { + const settings = options || {}; + const imageFiles = Array.from(fileList || []).filter(isImageFile); + if (!imageFiles.length) return 0; + if (!hasActiveOpenDocument() || !canMutateEditor()) { + announceToScreenReader(getEditorReadOnlyMessage()); + return 0; + } + + const acceptedFiles = imageFiles.filter(function(file) { + return !Number.isFinite(file.size) || file.size <= MAX_LOCAL_IMAGE_BYTES; + }); + if (acceptedFiles.length !== imageFiles.length) { + alert('Images larger than 25MB cannot be inserted.'); + } + if (!acceptedFiles.length) return 0; + + const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; + const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; + const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; + const markdown = acceptedFiles.map(function(file, index) { + return createLocalImageMarkdown(file, getLocalImageAltText(file, source, index, acceptedFiles.length)); + }).join('\n\n'); + replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted.'); + return acceptedFiles.length; + } + function wrapEditorSelection(prefix, suffix, placeholder) { const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; @@ -15350,6 +15493,26 @@ ${selector} .arrowheadPath { }); }); + markdownEditor.addEventListener('paste', function(event) { + const clipboard = event.clipboardData; + if (!clipboard) return; + const itemFiles = Array.from(clipboard.items || []).map(function(item) { + return item.kind === 'file' ? item.getAsFile() : null; + }).filter(Boolean); + const imageFiles = (itemFiles.length ? itemFiles : Array.from(clipboard.files || [])).filter(isImageFile); + if (!imageFiles.length) return; + event.preventDefault(); + if (!canMutateEditor()) { + announceToScreenReader(getEditorReadOnlyMessage()); + return; + } + insertImageFilesIntoEditor(imageFiles, { + source: 'clipboard', + selectionStart: markdownEditor.selectionStart, + selectionEnd: markdownEditor.selectionEnd + }); + }); + markdownEditor.addEventListener('keydown', updateLastCursor); markdownEditor.addEventListener('keyup', updateLastCursor); markdownEditor.addEventListener('mousedown', updateLastCursor); @@ -20193,6 +20356,7 @@ ${selector} .arrowheadPath { dragDepth = 0; dragOverlay.classList.remove("active"); dragOverlay.setAttribute("aria-hidden", "true"); + resetDocumentTreeDragFeedback(); handleDrop(e); }, false); @@ -20200,8 +20364,36 @@ ${selector} .arrowheadPath { const dt = e.dataTransfer; const files = dt && dt.files; if (files && files.length) { - await importMarkdownFiles(files); + await handleIncomingDroppedFiles(files, { + location: { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null } + }); + } + } + + async function handleIncomingDroppedFiles(fileList, options) { + const settings = options || {}; + const files = Array.from(fileList || []); + const imageFiles = files.filter(isImageFile); + const markdownFiles = files.filter(isMarkdownFile); + let handledCount = 0; + + if (imageFiles.length) { + const insertedCount = insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); + handledCount += insertedCount; + if (!insertedCount && !markdownFiles.length) { + alert('Open an editable Markdown file before inserting images.'); + } + } + if (markdownFiles.length) { + handledCount += await importMarkdownFiles(markdownFiles, { + location: settings.location || { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }, + showInvalidAlert: false + }); + } + if (!imageFiles.length && !markdownFiles.length) { + alert('Drop Markdown files or images to add them.'); } + return handledCount; } document.addEventListener("keydown", function (e) { diff --git a/styles.css b/styles.css index d5678526..a332fcb1 100644 --- a/styles.css +++ b/styles.css @@ -31,6 +31,7 @@ --segmented-surface: #ffffff; --segmented-shadow: 0 1px 2px rgba(31, 35, 40, 0.12), 0 0 0 0.5px rgba(31, 35, 40, 0.06); --layer-menu: 1100; + --layer-notice: 1200; --button-bg: #f6f8fa; --button-hover: #e1e4e8; --button-active: #d1d5da; @@ -2206,61 +2207,67 @@ body.document-sidebar-collapsed .document-sidebar { display: none; } -/* Drag overlay: full-screen drop target shown when user drags a file over the window */ +/* Compact drag notice that leaves the Explorer and editor available as drop targets. */ .drag-overlay { display: none; position: fixed; - inset: 0; - z-index: 9999; - background-color: rgba(0, 0, 0, 0.45); + inset: auto 20px 20px auto; + width: min(360px, calc(100vw - 40px)); + z-index: var(--layer-notice); pointer-events: none; - align-items: center; - justify-content: center; } .drag-overlay.active { - display: flex; - pointer-events: auto; + display: block; + pointer-events: none; } .drag-overlay-inner { - border: 3px dashed var(--accent-color); - border-radius: 12px; - padding: 48px 64px; - text-align: center; - color: #ffffff; - background-color: rgba(3, 102, 214, 0.15); - animation: overlayPulse 1.4s ease-in-out infinite; + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + column-gap: 10px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent-color) 55%, var(--border-color)); + border-radius: 9px; + color: var(--text-color); + background: color-mix(in srgb, var(--header-bg) 94%, transparent); + box-shadow: 0 10px 30px rgba(31, 35, 40, 0.2); + backdrop-filter: blur(10px); + animation: dragNoticeIn 0.18s ease-out; } .drag-overlay-icon { - display: block; - font-size: 3rem; - margin-bottom: 12px; + grid-row: 1 / span 2; + justify-self: center; + font-size: 21px; color: var(--accent-color); } .drag-overlay-text { - font-size: 1.4rem; + margin: 0; + font-size: 13px; font-weight: 600; - margin-bottom: 4px; + line-height: 1.35; } .drag-overlay-sub { - font-size: 0.85rem; - opacity: 0.75; - margin-bottom: 0; + margin: 1px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.35; } -@keyframes overlayPulse { - - 0%, - 100% { - transform: scale(1); +@keyframes dragNoticeIn { + from { + opacity: 0; + transform: translateY(6px); } - 50% { - transform: scale(1.015); + to { + opacity: 1; + transform: translateY(0); } } diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index a8eb5d00..c6065485 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -89,11 +89,13 @@ Scope matching uses Marked's lexer and is best-effort for unusual Markdown. ### Local Files -Use Import > From files, the mobile import button, or drag and drop to open local Markdown files. +Use Import > From files, the mobile import button, or drag and drop to open local Markdown files. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. + +Paste an image from the clipboard or drop an image file into the app to insert it at the current editor cursor as Markdown image syntax. - Supported file types are `.md`, `.markdown`, and `text/markdown`. - Extension matching is case-insensitive. -- Dragging over the app shows a drop overlay. +- Dragging over the app shows a compact notice without blocking the editor or Explorer drop targets. - The app scans the first 8 KB for null bytes and rejects likely binary files. - Local file content stays on the device unless you later share it. From 072d1d25d0a73f3b4ab71ad161d1cc4dfbc1625f Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 22 Jul 2026 20:44:52 +0530 Subject: [PATCH 02/10] fix(editor): persist images across sessions and shares --- CHANGELOG.md | 2 +- README.md | 3 +- _headers | 2 +- desktop-app/resources/index.html | 2 +- desktop-app/resources/js/script.js | 339 +++++++++++++++--- desktop-app/resources/styles.css | 73 ++++ functions/api/share/[[id]].js | 2 +- ...-ui-locales.mjs => generate-ui-locales.mjs | 6 +- index.html | 2 +- script.js | 339 +++++++++++++++--- styles.css | 73 ++++ sw.js | 2 +- wiki/Configuration.md | 6 +- wiki/Features.md | 15 +- wiki/Live-Share-Cloudflare.md | 2 +- wiki/Localization.md | 1 + wiki/Usage-Guide.md | 4 +- workers/live-room-worker.js | 2 +- 18 files changed, 766 insertions(+), 109 deletions(-) rename scripts/generate-ui-locales.mjs => generate-ui-locales.mjs (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f980d03..f3b08721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Non-code commits (documentation, planning, README-only updates) are excluded. ## Unreleased -- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, and added clipboard-paste and drag-and-drop image insertion. +- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized, embedded, persistent, and available in Snapshot and Live Share documents. ## v3.9.3 diff --git a/README.md b/README.md index 8a55c235..ad6127b4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. - Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, precise drag-and-drop moves, hover-to-expand folders, and edge auto-scrolling. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. -- Paste images from the clipboard or drop image files into the app to insert Markdown image syntax at the editor cursor. +- Paste, upload, or drop image files to insert optimized image data directly into the Markdown at the editor cursor. Embedded images survive refreshes and travel with Share Snapshot and Live Share documents; very large collections may need external image URLs to stay within browser storage limits. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. @@ -234,6 +234,7 @@ Markdown-Viewer/ +-- styles.css # App and preview styles +-- preview-worker.js # Markdown preview worker +-- sw.js # Service worker cache behavior ++-- generate-ui-locales.mjs # Root-level interface locale generator +-- assets/ # App images and icons +-- functions/ # Cloudflare Pages Functions +-- workers/ # Live Share Worker source diff --git a/_headers b/_headers index 769dde05..55e9462b 100644 --- a/_headers +++ b/_headers @@ -1,6 +1,6 @@ /* Strict-Transport-Security: max-age=63072000; includeSubDomains; preload - Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME=' 'sha256-NnbK2LG1LUwYyZ1xgJN7k3oK4oTvHY4o9852VxwNA/o='; script-src-attr 'none'; worker-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io wss://markdownviewer.pages.dev; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src-attr 'unsafe-inline'; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data:; manifest-src 'self'; frame-src 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME=' 'sha256-NnbK2LG1LUwYyZ1xgJN7k3oK4oTvHY4o9852VxwNA/o='; script-src-attr 'none'; worker-src 'self'; connect-src 'self' https://markdownviewer.pages.dev https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io wss://markdownviewer.pages.dev; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src-attr 'unsafe-inline'; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data:; manifest-src 'self'; frame-src 'none'; upgrade-insecure-requests X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index db07d531..f035c3c0 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -3,7 +3,7 @@ - + diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 169e8876..3272f1f6 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -254,10 +254,11 @@ document.addEventListener("DOMContentLoaded", async function () { const PREVIEW_WORKER_TIMEOUT = 12000; const PREVIEW_SEGMENT_MIN_BLOCKS = 8; const PREVIEW_BLOCK_REUSE_LIMIT = 12000; + const SAFE_MARKDOWN_URI_REGEXP = /^(?:(?:https?|mailto|tel|blob):|data:image\/(?:avif|bmp|gif|jpe?g|png|webp);base64,|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; const PREVIEW_SANITIZE_OPTIONS = { ADD_TAGS: ['mjx-container', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code', 'role', 'aria-labelledby', 'aria-describedby'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }; const RENDER_DELAY = 100; const LARGE_RENDER_DELAY = 160; @@ -569,8 +570,12 @@ document.addEventListener("DOMContentLoaded", async function () { // ======================================== const GLOBAL_STATE_KEY = 'markdownViewerGlobalState'; let referenceCounter = 1; - const imageObjectUrls = new Set(); const MAX_LOCAL_IMAGE_BYTES = 25 * 1024 * 1024; + const EMBEDDED_IMAGE_TARGET_BYTES = 180 * 1024; + const EMBEDDED_IMAGE_MAX_BYTES = 280 * 1024; + const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; + const MAX_NORMAL_WORKSPACE_STORAGE_CHARS = 4 * 1024 * 1024; + const MAX_SECRET_WORKSPACE_STORAGE_CHARS = 3 * 1024 * 1024; const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -2502,6 +2507,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; let draggedSidebarDocumentId = null; + let activeDocumentDragPreview = null; let documentTreeDragExpandTimer = null; let documentTreeDragExpandRow = null; let documentTreeAutoScrollFrame = null; @@ -4019,6 +4025,42 @@ document.addEventListener("DOMContentLoaded", async function () { stopDocumentTreeAutoScroll(); } + function removeDocumentDragPreview() { + if (activeDocumentDragPreview && activeDocumentDragPreview.parentNode) { + activeDocumentDragPreview.parentNode.removeChild(activeDocumentDragPreview); + } + activeDocumentDragPreview = null; + } + + function createDocumentDragPreview(title) { + removeDocumentDragPreview(); + const normalizedTitle = String(title || 'Untitled').trim() || 'Untitled'; + const filename = /\.md$/i.test(normalizedTitle) ? normalizedTitle : normalizedTitle + '.md'; + const preview = document.createElement('div'); + preview.className = 'document-drag-preview'; + preview.setAttribute('aria-hidden', 'true'); + + const page = document.createElement('span'); + page.className = 'document-drag-preview-page'; + const icon = document.createElement('i'); + icon.className = 'lucide lucide-download'; + icon.setAttribute('aria-hidden', 'true'); + const badge = document.createElement('span'); + badge.className = 'document-drag-preview-badge'; + badge.textContent = 'MD'; + page.appendChild(icon); + page.appendChild(badge); + + const label = document.createElement('span'); + label.className = 'document-drag-preview-label'; + label.textContent = filename; + preview.appendChild(page); + preview.appendChild(label); + document.body.appendChild(preview); + activeDocumentDragPreview = preview; + return preview; + } + function attachDocumentDropTarget(row, location) { if (!row || !location) return; row.setAttribute('data-drop-workspace-id', location.workspaceId); @@ -4172,11 +4214,16 @@ document.addEventListener("DOMContentLoaded", async function () { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-markdown-viewer-document', options.documentId); event.dataTransfer.setData('text/plain', options.documentId); + const preview = createDocumentDragPreview(options.label); + try { + event.dataTransfer.setDragImage(preview, 36, 34); + } catch (_) {} } }); row.addEventListener('dragend', function() { draggedSidebarDocumentId = null; row.classList.remove('is-dragging'); + removeDocumentDragPreview(); resetDocumentTreeDragFeedback(); }); } @@ -9332,7 +9379,6 @@ ${selector} .arrowheadPath { decorateReviewTargets(); updateDocumentStats(); updateFindHighlights(); - cleanupImageObjectUrls(); scheduleLineNumberUpdate(); scheduleAdvancedPostProcessRecovery(rawVal, context); } @@ -10436,13 +10482,167 @@ ${selector} .arrowheadPath { return total > 1 ? label + ' ' + (index + 1) : label; } - function createLocalImageMarkdown(file, altText) { - const objectUrl = URL.createObjectURL(file); - imageObjectUrls.add(objectUrl); - return '![' + altText + '](' + objectUrl + ')'; + function getEmbeddedRasterMime(file) { + const suppliedType = String(file && file.type ? file.type : '').toLowerCase(); + const normalizedType = suppliedType === 'image/jpg' ? 'image/jpeg' : suppliedType; + if (/^image\/(?:avif|bmp|gif|jpeg|png|webp)$/.test(normalizedType)) return normalizedType; + const extension = String(file && file.name ? file.name : '').toLowerCase().match(/\.([a-z0-9]+)$/); + return { + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' + }[extension ? extension[1] : ''] || ''; + } + + function readFileAsDataUrl(file) { + return new Promise(function(resolve, reject) { + const reader = new FileReader(); + reader.addEventListener('load', function() { resolve(String(reader.result || '')); }, { once: true }); + reader.addEventListener('error', function() { reject(reader.error || new Error('Image could not be read.')); }, { once: true }); + reader.readAsDataURL(file); + }); + } + + function getEmbeddedDataByteLength(dataUrl) { + const separatorIndex = String(dataUrl || '').indexOf(','); + if (separatorIndex === -1) return Number.POSITIVE_INFINITY; + const encodedLength = dataUrl.length - separatorIndex - 1; + return Math.ceil(encodedLength * 0.75); + } + + function loadImageForEmbedding(file) { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(file).then(function(bitmap) { + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + release: function() { bitmap.close(); } + }; + }).catch(function() { + return loadImageElementForEmbedding(file); + }); + } + return loadImageElementForEmbedding(file); } - function insertImageFilesIntoEditor(fileList, options) { + function loadImageElementForEmbedding(file) { + return new Promise(function(resolve, reject) { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + image.addEventListener('load', function() { + resolve({ + source: image, + width: image.naturalWidth, + height: image.naturalHeight, + release: function() { URL.revokeObjectURL(objectUrl); } + }); + }, { once: true }); + image.addEventListener('error', function() { + URL.revokeObjectURL(objectUrl); + reject(new Error('The selected file is not a supported image.')); + }, { once: true }); + image.src = objectUrl; + }); + } + + async function createEmbeddedImageDataUrl(file) { + const rasterMime = getEmbeddedRasterMime(file); + if (rasterMime && file.size <= EMBEDDED_IMAGE_TARGET_BYTES) { + const originalDataUrl = await readFileAsDataUrl(file); + const encoded = originalDataUrl.slice(originalDataUrl.indexOf(',') + 1); + if (encoded && getEmbeddedDataByteLength(originalDataUrl) <= EMBEDDED_IMAGE_MAX_BYTES) { + return 'data:' + rasterMime + ';base64,' + encoded; + } + } + + const decoded = await loadImageForEmbedding(file); + try { + if (!decoded.width || !decoded.height) throw new Error('The selected image has no visible pixels.'); + const initialScale = Math.min(1, EMBEDDED_IMAGE_MAX_DIMENSION / Math.max(decoded.width, decoded.height)); + let width = Math.max(1, Math.round(decoded.width * initialScale)); + let height = Math.max(1, Math.round(decoded.height * initialScale)); + let quality = 0.84; + let dataUrl = ''; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d', { alpha: true }); + if (!context) throw new Error('This browser cannot prepare the image.'); + + for (let attempt = 0; attempt < 12; attempt += 1) { + canvas.width = width; + canvas.height = height; + context.drawImage(decoded.source, 0, 0, width, height); + dataUrl = canvas.toDataURL('image/webp', quality); + const preparedBytes = getEmbeddedDataByteLength(dataUrl); + if (preparedBytes <= EMBEDDED_IMAGE_TARGET_BYTES) break; + + if (quality > 0.58) { + quality = Math.max(0.58, quality - 0.08); + } else { + const reduction = Math.max(0.68, Math.min(0.86, Math.sqrt(EMBEDDED_IMAGE_TARGET_BYTES / preparedBytes) * 0.96)); + width = Math.max(1, Math.round(width * reduction)); + height = Math.max(1, Math.round(height * reduction)); + quality = 0.76; + } + } + + if (!/^data:image\/webp;base64,/i.test(dataUrl) || getEmbeddedDataByteLength(dataUrl) > EMBEDDED_IMAGE_MAX_BYTES) { + throw new Error('The image remains too large after optimization.'); + } + return dataUrl; + } finally { + decoded.release(); + } + } + + function canPersistImageInsertion(targetTabId, nextContent) { + if (isPrivateStorageMode() || (liveCollaboration && liveCollaboration.tabId === targetTabId)) return true; + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + if (!targetTab || isTemporaryDocument(targetTab)) return true; + + if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { + const payload = getSecretWorkspacePayload(); + payload.documents = payload.documents.map(function(tab) { + return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + }); + return JSON.stringify(payload).length <= MAX_SECRET_WORKSPACE_STORAGE_CHARS; + } + + const storageTabs = getTabsForStorage(tabs).map(function(tab) { + return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + }); + return JSON.stringify(storageTabs).length <= MAX_NORMAL_WORKSPACE_STORAGE_CHARS; + } + + async function persistImageInsertion(targetTabId) { + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + if (!targetTab || isTemporaryDocument(targetTab) || isPrivateStorageMode()) return true; + if (liveCollaboration && liveCollaboration.tabId === targetTabId) return true; + saveCurrentTabState(); + if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { + await flushSecretWorkspaceToStorage(); + return true; + } + return _flushTabsToStorage(tabs); + } + + function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd) { + if (activeTabId !== targetTabId) return; + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + markdownEditor.value = originalValue; + markdownEditor.setSelectionRange(selectionStart, selectionEnd); + lastPushedValue = originalValue; + if (targetTab) targetTab.content = originalValue; + renderMarkdown({ reason: 'image-storage-rollback', force: true }); + updateDocumentStats(); + scheduleLineNumberUpdate({ force: true }); + } + + async function insertImageFilesIntoEditor(fileList, options) { const settings = options || {}; const imageFiles = Array.from(fileList || []).filter(isImageFile); if (!imageFiles.length) return 0; @@ -10459,15 +10659,54 @@ ${selector} .arrowheadPath { } if (!acceptedFiles.length) return 0; + const targetTabId = activeTabId; + const originalValue = markdownEditor.value; const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; - const markdown = acceptedFiles.map(function(file, index) { - return createLocalImageMarkdown(file, getLocalImageAltText(file, source, index, acceptedFiles.length)); - }).join('\n\n'); - replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); - announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted.'); - return acceptedFiles.length; + let didInsert = false; + announceToScreenReader('Preparing ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + '.'); + + try { + const preparedImages = []; + for (const file of acceptedFiles) { + preparedImages.push(await createEmbeddedImageDataUrl(file)); + } + if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + announceToScreenReader('Image insertion was cancelled because the document changed.'); + return 0; + } + + const markdown = preparedImages.map(function(dataUrl, index) { + const file = acceptedFiles[index]; + const altText = typeof settings.altText === 'string' && acceptedFiles.length === 1 + ? (settings.altText.trim() || 'alt text') + : getLocalImageAltText(file, source, index, acceptedFiles.length); + if (typeof settings.markdownFactory === 'function') { + return settings.markdownFactory(dataUrl, altText, file, index, acceptedFiles.length); + } + return '![' + altText + '](' + dataUrl + ')'; + }).join('\n\n'); + const nextValue = originalValue.slice(0, start) + markdown + originalValue.slice(end); + if (!canPersistImageInsertion(targetTabId, nextValue)) { + alert('These images would exceed safe local browser storage. Insert fewer images, use smaller files, or use external image URLs.'); + return 0; + } + + replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + didInsert = true; + const persisted = await persistImageInsertion(targetTabId); + if (!persisted) throw new Error('The browser could not save the embedded image.'); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted and saved with the document.'); + return acceptedFiles.length; + } catch (error) { + if (didInsert) { + restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end); + } + console.warn('Image insertion failed:', error); + alert(error && error.message ? error.message : 'The image could not be inserted.'); + return 0; + } } function wrapEditorSelection(prefix, suffix, placeholder) { @@ -11106,25 +11345,6 @@ ${selector} .arrowheadPath { }); } - function cleanupImageObjectUrls() { - if (imageObjectUrls.size === 0) return; - const contents = [markdownEditor.value]; - if (Array.isArray(tabs)) { - tabs.forEach(function(tab) { - if (tab && typeof tab.content === 'string' && tab.content) { - contents.push(tab.content); - } - }); - } - const snapshot = contents.join('\n'); - Array.from(imageObjectUrls).forEach(function(url) { - if (!snapshot.includes(url)) { - URL.revokeObjectURL(url); - imageObjectUrls.delete(url); - } - }); - } - function insertAlignmentBlock(align) { const allowedAlignments = new Set(['left', 'center', 'right']); const isAllowed = allowedAlignments.has(align); @@ -13142,6 +13362,8 @@ ${selector} .arrowheadPath { urlOption.checked = true; uploadOption.checked = false; modal.style.display = 'flex'; + let isProcessing = false; + const confirmButtonLabel = confirmBtn.textContent; function buildImageMarkdown(url) { const titleText = altInput.value.trim(); @@ -13159,10 +13381,33 @@ ${selector} .arrowheadPath { replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } - function insertFromFile(file) { - const objectUrl = URL.createObjectURL(file); - imageObjectUrls.add(objectUrl); - insertImage(objectUrl); + function setProcessing(processing) { + isProcessing = processing; + modal.setAttribute('aria-busy', processing ? 'true' : 'false'); + confirmBtn.disabled = processing; + confirmBtn.textContent = processing ? translateUiString('Preparing') + '…' : confirmButtonLabel; + cancelBtn.disabled = processing; + uploadOption.disabled = processing; + urlOption.disabled = processing; + fileInput.disabled = processing; + urlInput.disabled = processing; + altInput.disabled = processing; + } + + async function insertFromFile(file) { + if (isProcessing) return; + setProcessing(true); + const insertedCount = await insertImageFilesIntoEditor([file], { + source: 'upload', + selectionStart: start, + selectionEnd: end, + altText: altInput.value, + markdownFactory: function(dataUrl) { + return buildImageMarkdown(dataUrl); + } + }); + setProcessing(false); + if (insertedCount) closeModal(); } function updateMode(shouldFocus) { @@ -13193,6 +13438,10 @@ ${selector} .arrowheadPath { } function onKey(e) { + if (isProcessing) { + e.preventDefault(); + return; + } if (e.key === 'Enter') { e.preventDefault(); if (uploadOption.checked) { @@ -13209,7 +13458,9 @@ ${selector} .arrowheadPath { } function closeModal() { + if (isProcessing) return; modal.style.display = 'none'; + modal.removeAttribute('aria-busy'); cleanup(); } @@ -15493,7 +15744,7 @@ ${selector} .arrowheadPath { }); }); - markdownEditor.addEventListener('paste', function(event) { + markdownEditor.addEventListener('paste', async function(event) { const clipboard = event.clipboardData; if (!clipboard) return; const itemFiles = Array.from(clipboard.items || []).map(function(item) { @@ -15506,7 +15757,7 @@ ${selector} .arrowheadPath { announceToScreenReader(getEditorReadOnlyMessage()); return; } - insertImageFilesIntoEditor(imageFiles, { + await insertImageFilesIntoEditor(imageFiles, { source: 'clipboard', selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd @@ -15802,7 +16053,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -17218,7 +17469,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); throwIfPdfExportAborted(progressState.signal); @@ -17535,7 +17786,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); throwIfPdfExportAborted(progressState.signal); @@ -20322,7 +20573,7 @@ ${selector} .arrowheadPath { loadFromShareHash(); loadFromLiveHash(); - // Full-window drag-and-drop: track nesting level for reliable enter/leave detection + // Document-wide drag-and-drop: track nesting level for reliable enter/leave detection let dragDepth = 0; document.addEventListener("dragenter", function(e) { @@ -20378,7 +20629,7 @@ ${selector} .arrowheadPath { let handledCount = 0; if (imageFiles.length) { - const insertedCount = insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); + const insertedCount = await insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); handledCount += insertedCount; if (!insertedCount && !markdownFiles.length) { alert('Open an editable Markdown file before inserting images.'); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index a332fcb1..5a1bfa95 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -540,6 +540,79 @@ body { opacity: 0.45; } +.document-drag-preview { + position: fixed; + top: -160px; + left: -160px; + z-index: var(--layer-notice); + display: flex; + width: 88px; + flex-direction: column; + align-items: center; + gap: 5px; + pointer-events: none; +} + +.document-drag-preview-page { + position: relative; + display: grid; + width: 48px; + height: 58px; + place-items: center; + border: 1px solid #b8c0cc; + border-radius: 2px; + background: #ffffff; + box-shadow: 0 3px 8px rgba(31, 35, 40, 0.2); + color: #52606f; +} + +.document-drag-preview-page::after { + position: absolute; + top: -1px; + right: -1px; + width: 13px; + height: 13px; + border-bottom: 1px solid #b8c0cc; + border-left: 1px solid #b8c0cc; + background: linear-gradient(225deg, var(--editor-bg) 49%, #ffffff 51%); + content: ''; +} + +.document-drag-preview-page > i { + margin-top: 2px; + color: var(--accent-color); + font-size: 22px; +} + +.document-drag-preview-badge { + position: absolute; + right: 3px; + bottom: 3px; + padding: 1px 3px; + border-radius: 3px; + background: var(--accent-color); + color: #ffffff; + font-size: 7px; + font-weight: 800; + letter-spacing: 0.03em; + line-height: 1.35; +} + +.document-drag-preview-label { + overflow: hidden; + max-width: 88px; + padding: 2px 5px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--toolbar-surface); + box-shadow: 0 2px 5px rgba(31, 35, 40, 0.14); + color: var(--text-color); + font-size: var(--ui-font-xs); + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + .document-tree-row.is-drop-target { background: color-mix(in srgb, var(--accent-color) 14%, var(--button-bg)); box-shadow: inset 0 0 0 1px var(--accent-color); diff --git a/functions/api/share/[[id]].js b/functions/api/share/[[id]].js index 4cc25dcd..5ec1829a 100644 --- a/functions/api/share/[[id]].js +++ b/functions/api/share/[[id]].js @@ -1,5 +1,5 @@ const SHARE_TTL_SECONDS = 60 * 60 * 24 * 90; -const MAX_CONTENT_CHARS = 500000; +const MAX_CONTENT_CHARS = 8000000; const SHARE_ID_LENGTH = 10; const SHARE_DELETE_TOKEN_BYTES = 18; const SHARE_ID_PATTERN = /^[a-z2-9]{6,20}$/; diff --git a/scripts/generate-ui-locales.mjs b/generate-ui-locales.mjs similarity index 99% rename from scripts/generate-ui-locales.mjs rename to generate-ui-locales.mjs index 87e9beb9..c73168ff 100644 --- a/scripts/generate-ui-locales.mjs +++ b/generate-ui-locales.mjs @@ -4,9 +4,9 @@ import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { chromium } from '@playwright/test'; -const ROOT = new URL('../', import.meta.url); +const ROOT = new URL('./', import.meta.url); const ROOT_PATH = fileURLToPath(ROOT); -const OUTPUT_DIR = new URL('../assets/i18n/', import.meta.url); +const OUTPUT_DIR = new URL('./assets/i18n/', import.meta.url); const PORT = 4197; const HOST = '127.0.0.1'; const FORCE = process.argv.includes('--force'); @@ -414,7 +414,7 @@ async function main() { return; } const domStrings = await collectDomStrings(); - const scriptSource = await readFile(new URL('../script.js', import.meta.url), 'utf8'); + const scriptSource = await readFile(new URL('./script.js', import.meta.url), 'utf8'); const allStrings = new Set([...domStrings, ...extractScriptStrings(scriptSource), ...EXTRA_STRINGS]); const strings = Array.from(allStrings).map(normalize).filter(isTranslatable).sort((a, b) => a.localeCompare(b)); diff --git a/index.html b/index.html index ddd508e6..7d7b9627 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/script.js b/script.js index 169e8876..3272f1f6 100644 --- a/script.js +++ b/script.js @@ -254,10 +254,11 @@ document.addEventListener("DOMContentLoaded", async function () { const PREVIEW_WORKER_TIMEOUT = 12000; const PREVIEW_SEGMENT_MIN_BLOCKS = 8; const PREVIEW_BLOCK_REUSE_LIMIT = 12000; + const SAFE_MARKDOWN_URI_REGEXP = /^(?:(?:https?|mailto|tel|blob):|data:image\/(?:avif|bmp|gif|jpe?g|png|webp);base64,|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i; const PREVIEW_SANITIZE_OPTIONS = { ADD_TAGS: ['mjx-container', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code', 'role', 'aria-labelledby', 'aria-describedby'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }; const RENDER_DELAY = 100; const LARGE_RENDER_DELAY = 160; @@ -569,8 +570,12 @@ document.addEventListener("DOMContentLoaded", async function () { // ======================================== const GLOBAL_STATE_KEY = 'markdownViewerGlobalState'; let referenceCounter = 1; - const imageObjectUrls = new Set(); const MAX_LOCAL_IMAGE_BYTES = 25 * 1024 * 1024; + const EMBEDDED_IMAGE_TARGET_BYTES = 180 * 1024; + const EMBEDDED_IMAGE_MAX_BYTES = 280 * 1024; + const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; + const MAX_NORMAL_WORKSPACE_STORAGE_CHARS = 4 * 1024 * 1024; + const MAX_SECRET_WORKSPACE_STORAGE_CHARS = 3 * 1024 * 1024; const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -2502,6 +2507,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; let draggedSidebarDocumentId = null; + let activeDocumentDragPreview = null; let documentTreeDragExpandTimer = null; let documentTreeDragExpandRow = null; let documentTreeAutoScrollFrame = null; @@ -4019,6 +4025,42 @@ document.addEventListener("DOMContentLoaded", async function () { stopDocumentTreeAutoScroll(); } + function removeDocumentDragPreview() { + if (activeDocumentDragPreview && activeDocumentDragPreview.parentNode) { + activeDocumentDragPreview.parentNode.removeChild(activeDocumentDragPreview); + } + activeDocumentDragPreview = null; + } + + function createDocumentDragPreview(title) { + removeDocumentDragPreview(); + const normalizedTitle = String(title || 'Untitled').trim() || 'Untitled'; + const filename = /\.md$/i.test(normalizedTitle) ? normalizedTitle : normalizedTitle + '.md'; + const preview = document.createElement('div'); + preview.className = 'document-drag-preview'; + preview.setAttribute('aria-hidden', 'true'); + + const page = document.createElement('span'); + page.className = 'document-drag-preview-page'; + const icon = document.createElement('i'); + icon.className = 'lucide lucide-download'; + icon.setAttribute('aria-hidden', 'true'); + const badge = document.createElement('span'); + badge.className = 'document-drag-preview-badge'; + badge.textContent = 'MD'; + page.appendChild(icon); + page.appendChild(badge); + + const label = document.createElement('span'); + label.className = 'document-drag-preview-label'; + label.textContent = filename; + preview.appendChild(page); + preview.appendChild(label); + document.body.appendChild(preview); + activeDocumentDragPreview = preview; + return preview; + } + function attachDocumentDropTarget(row, location) { if (!row || !location) return; row.setAttribute('data-drop-workspace-id', location.workspaceId); @@ -4172,11 +4214,16 @@ document.addEventListener("DOMContentLoaded", async function () { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-markdown-viewer-document', options.documentId); event.dataTransfer.setData('text/plain', options.documentId); + const preview = createDocumentDragPreview(options.label); + try { + event.dataTransfer.setDragImage(preview, 36, 34); + } catch (_) {} } }); row.addEventListener('dragend', function() { draggedSidebarDocumentId = null; row.classList.remove('is-dragging'); + removeDocumentDragPreview(); resetDocumentTreeDragFeedback(); }); } @@ -9332,7 +9379,6 @@ ${selector} .arrowheadPath { decorateReviewTargets(); updateDocumentStats(); updateFindHighlights(); - cleanupImageObjectUrls(); scheduleLineNumberUpdate(); scheduleAdvancedPostProcessRecovery(rawVal, context); } @@ -10436,13 +10482,167 @@ ${selector} .arrowheadPath { return total > 1 ? label + ' ' + (index + 1) : label; } - function createLocalImageMarkdown(file, altText) { - const objectUrl = URL.createObjectURL(file); - imageObjectUrls.add(objectUrl); - return '![' + altText + '](' + objectUrl + ')'; + function getEmbeddedRasterMime(file) { + const suppliedType = String(file && file.type ? file.type : '').toLowerCase(); + const normalizedType = suppliedType === 'image/jpg' ? 'image/jpeg' : suppliedType; + if (/^image\/(?:avif|bmp|gif|jpeg|png|webp)$/.test(normalizedType)) return normalizedType; + const extension = String(file && file.name ? file.name : '').toLowerCase().match(/\.([a-z0-9]+)$/); + return { + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp' + }[extension ? extension[1] : ''] || ''; + } + + function readFileAsDataUrl(file) { + return new Promise(function(resolve, reject) { + const reader = new FileReader(); + reader.addEventListener('load', function() { resolve(String(reader.result || '')); }, { once: true }); + reader.addEventListener('error', function() { reject(reader.error || new Error('Image could not be read.')); }, { once: true }); + reader.readAsDataURL(file); + }); + } + + function getEmbeddedDataByteLength(dataUrl) { + const separatorIndex = String(dataUrl || '').indexOf(','); + if (separatorIndex === -1) return Number.POSITIVE_INFINITY; + const encodedLength = dataUrl.length - separatorIndex - 1; + return Math.ceil(encodedLength * 0.75); + } + + function loadImageForEmbedding(file) { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(file).then(function(bitmap) { + return { + source: bitmap, + width: bitmap.width, + height: bitmap.height, + release: function() { bitmap.close(); } + }; + }).catch(function() { + return loadImageElementForEmbedding(file); + }); + } + return loadImageElementForEmbedding(file); } - function insertImageFilesIntoEditor(fileList, options) { + function loadImageElementForEmbedding(file) { + return new Promise(function(resolve, reject) { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + image.addEventListener('load', function() { + resolve({ + source: image, + width: image.naturalWidth, + height: image.naturalHeight, + release: function() { URL.revokeObjectURL(objectUrl); } + }); + }, { once: true }); + image.addEventListener('error', function() { + URL.revokeObjectURL(objectUrl); + reject(new Error('The selected file is not a supported image.')); + }, { once: true }); + image.src = objectUrl; + }); + } + + async function createEmbeddedImageDataUrl(file) { + const rasterMime = getEmbeddedRasterMime(file); + if (rasterMime && file.size <= EMBEDDED_IMAGE_TARGET_BYTES) { + const originalDataUrl = await readFileAsDataUrl(file); + const encoded = originalDataUrl.slice(originalDataUrl.indexOf(',') + 1); + if (encoded && getEmbeddedDataByteLength(originalDataUrl) <= EMBEDDED_IMAGE_MAX_BYTES) { + return 'data:' + rasterMime + ';base64,' + encoded; + } + } + + const decoded = await loadImageForEmbedding(file); + try { + if (!decoded.width || !decoded.height) throw new Error('The selected image has no visible pixels.'); + const initialScale = Math.min(1, EMBEDDED_IMAGE_MAX_DIMENSION / Math.max(decoded.width, decoded.height)); + let width = Math.max(1, Math.round(decoded.width * initialScale)); + let height = Math.max(1, Math.round(decoded.height * initialScale)); + let quality = 0.84; + let dataUrl = ''; + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d', { alpha: true }); + if (!context) throw new Error('This browser cannot prepare the image.'); + + for (let attempt = 0; attempt < 12; attempt += 1) { + canvas.width = width; + canvas.height = height; + context.drawImage(decoded.source, 0, 0, width, height); + dataUrl = canvas.toDataURL('image/webp', quality); + const preparedBytes = getEmbeddedDataByteLength(dataUrl); + if (preparedBytes <= EMBEDDED_IMAGE_TARGET_BYTES) break; + + if (quality > 0.58) { + quality = Math.max(0.58, quality - 0.08); + } else { + const reduction = Math.max(0.68, Math.min(0.86, Math.sqrt(EMBEDDED_IMAGE_TARGET_BYTES / preparedBytes) * 0.96)); + width = Math.max(1, Math.round(width * reduction)); + height = Math.max(1, Math.round(height * reduction)); + quality = 0.76; + } + } + + if (!/^data:image\/webp;base64,/i.test(dataUrl) || getEmbeddedDataByteLength(dataUrl) > EMBEDDED_IMAGE_MAX_BYTES) { + throw new Error('The image remains too large after optimization.'); + } + return dataUrl; + } finally { + decoded.release(); + } + } + + function canPersistImageInsertion(targetTabId, nextContent) { + if (isPrivateStorageMode() || (liveCollaboration && liveCollaboration.tabId === targetTabId)) return true; + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + if (!targetTab || isTemporaryDocument(targetTab)) return true; + + if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { + const payload = getSecretWorkspacePayload(); + payload.documents = payload.documents.map(function(tab) { + return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + }); + return JSON.stringify(payload).length <= MAX_SECRET_WORKSPACE_STORAGE_CHARS; + } + + const storageTabs = getTabsForStorage(tabs).map(function(tab) { + return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + }); + return JSON.stringify(storageTabs).length <= MAX_NORMAL_WORKSPACE_STORAGE_CHARS; + } + + async function persistImageInsertion(targetTabId) { + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + if (!targetTab || isTemporaryDocument(targetTab) || isPrivateStorageMode()) return true; + if (liveCollaboration && liveCollaboration.tabId === targetTabId) return true; + saveCurrentTabState(); + if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { + await flushSecretWorkspaceToStorage(); + return true; + } + return _flushTabsToStorage(tabs); + } + + function restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, selectionStart, selectionEnd) { + if (activeTabId !== targetTabId) return; + const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); + markdownEditor.value = originalValue; + markdownEditor.setSelectionRange(selectionStart, selectionEnd); + lastPushedValue = originalValue; + if (targetTab) targetTab.content = originalValue; + renderMarkdown({ reason: 'image-storage-rollback', force: true }); + updateDocumentStats(); + scheduleLineNumberUpdate({ force: true }); + } + + async function insertImageFilesIntoEditor(fileList, options) { const settings = options || {}; const imageFiles = Array.from(fileList || []).filter(isImageFile); if (!imageFiles.length) return 0; @@ -10459,15 +10659,54 @@ ${selector} .arrowheadPath { } if (!acceptedFiles.length) return 0; + const targetTabId = activeTabId; + const originalValue = markdownEditor.value; const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; - const markdown = acceptedFiles.map(function(file, index) { - return createLocalImageMarkdown(file, getLocalImageAltText(file, source, index, acceptedFiles.length)); - }).join('\n\n'); - replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); - announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted.'); - return acceptedFiles.length; + let didInsert = false; + announceToScreenReader('Preparing ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + '.'); + + try { + const preparedImages = []; + for (const file of acceptedFiles) { + preparedImages.push(await createEmbeddedImageDataUrl(file)); + } + if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + announceToScreenReader('Image insertion was cancelled because the document changed.'); + return 0; + } + + const markdown = preparedImages.map(function(dataUrl, index) { + const file = acceptedFiles[index]; + const altText = typeof settings.altText === 'string' && acceptedFiles.length === 1 + ? (settings.altText.trim() || 'alt text') + : getLocalImageAltText(file, source, index, acceptedFiles.length); + if (typeof settings.markdownFactory === 'function') { + return settings.markdownFactory(dataUrl, altText, file, index, acceptedFiles.length); + } + return '![' + altText + '](' + dataUrl + ')'; + }).join('\n\n'); + const nextValue = originalValue.slice(0, start) + markdown + originalValue.slice(end); + if (!canPersistImageInsertion(targetTabId, nextValue)) { + alert('These images would exceed safe local browser storage. Insert fewer images, use smaller files, or use external image URLs.'); + return 0; + } + + replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); + didInsert = true; + const persisted = await persistImageInsertion(targetTabId); + if (!persisted) throw new Error('The browser could not save the embedded image.'); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted and saved with the document.'); + return acceptedFiles.length; + } catch (error) { + if (didInsert) { + restoreEditorAfterFailedImageInsertion(targetTabId, originalValue, start, end); + } + console.warn('Image insertion failed:', error); + alert(error && error.message ? error.message : 'The image could not be inserted.'); + return 0; + } } function wrapEditorSelection(prefix, suffix, placeholder) { @@ -11106,25 +11345,6 @@ ${selector} .arrowheadPath { }); } - function cleanupImageObjectUrls() { - if (imageObjectUrls.size === 0) return; - const contents = [markdownEditor.value]; - if (Array.isArray(tabs)) { - tabs.forEach(function(tab) { - if (tab && typeof tab.content === 'string' && tab.content) { - contents.push(tab.content); - } - }); - } - const snapshot = contents.join('\n'); - Array.from(imageObjectUrls).forEach(function(url) { - if (!snapshot.includes(url)) { - URL.revokeObjectURL(url); - imageObjectUrls.delete(url); - } - }); - } - function insertAlignmentBlock(align) { const allowedAlignments = new Set(['left', 'center', 'right']); const isAllowed = allowedAlignments.has(align); @@ -13142,6 +13362,8 @@ ${selector} .arrowheadPath { urlOption.checked = true; uploadOption.checked = false; modal.style.display = 'flex'; + let isProcessing = false; + const confirmButtonLabel = confirmBtn.textContent; function buildImageMarkdown(url) { const titleText = altInput.value.trim(); @@ -13159,10 +13381,33 @@ ${selector} .arrowheadPath { replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } - function insertFromFile(file) { - const objectUrl = URL.createObjectURL(file); - imageObjectUrls.add(objectUrl); - insertImage(objectUrl); + function setProcessing(processing) { + isProcessing = processing; + modal.setAttribute('aria-busy', processing ? 'true' : 'false'); + confirmBtn.disabled = processing; + confirmBtn.textContent = processing ? translateUiString('Preparing') + '…' : confirmButtonLabel; + cancelBtn.disabled = processing; + uploadOption.disabled = processing; + urlOption.disabled = processing; + fileInput.disabled = processing; + urlInput.disabled = processing; + altInput.disabled = processing; + } + + async function insertFromFile(file) { + if (isProcessing) return; + setProcessing(true); + const insertedCount = await insertImageFilesIntoEditor([file], { + source: 'upload', + selectionStart: start, + selectionEnd: end, + altText: altInput.value, + markdownFactory: function(dataUrl) { + return buildImageMarkdown(dataUrl); + } + }); + setProcessing(false); + if (insertedCount) closeModal(); } function updateMode(shouldFocus) { @@ -13193,6 +13438,10 @@ ${selector} .arrowheadPath { } function onKey(e) { + if (isProcessing) { + e.preventDefault(); + return; + } if (e.key === 'Enter') { e.preventDefault(); if (uploadOption.checked) { @@ -13209,7 +13458,9 @@ ${selector} .arrowheadPath { } function closeModal() { + if (isProcessing) return; modal.style.display = 'none'; + modal.removeAttribute('aria-busy'); cleanup(); } @@ -15493,7 +15744,7 @@ ${selector} .arrowheadPath { }); }); - markdownEditor.addEventListener('paste', function(event) { + markdownEditor.addEventListener('paste', async function(event) { const clipboard = event.clipboardData; if (!clipboard) return; const itemFiles = Array.from(clipboard.items || []).map(function(item) { @@ -15506,7 +15757,7 @@ ${selector} .arrowheadPath { announceToScreenReader(getEditorReadOnlyMessage()); return; } - insertImageFilesIntoEditor(imageFiles, { + await insertImageFilesIntoEditor(imageFiles, { source: 'clipboard', selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd @@ -15802,7 +16053,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -17218,7 +17469,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); throwIfPdfExportAborted(progressState.signal); @@ -17535,7 +17786,7 @@ ${selector} .arrowheadPath { const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: SAFE_MARKDOWN_URI_REGEXP }); throwIfPdfExportAborted(progressState.signal); @@ -20322,7 +20573,7 @@ ${selector} .arrowheadPath { loadFromShareHash(); loadFromLiveHash(); - // Full-window drag-and-drop: track nesting level for reliable enter/leave detection + // Document-wide drag-and-drop: track nesting level for reliable enter/leave detection let dragDepth = 0; document.addEventListener("dragenter", function(e) { @@ -20378,7 +20629,7 @@ ${selector} .arrowheadPath { let handledCount = 0; if (imageFiles.length) { - const insertedCount = insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); + const insertedCount = await insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); handledCount += insertedCount; if (!insertedCount && !markdownFiles.length) { alert('Open an editable Markdown file before inserting images.'); diff --git a/styles.css b/styles.css index a332fcb1..5a1bfa95 100644 --- a/styles.css +++ b/styles.css @@ -540,6 +540,79 @@ body { opacity: 0.45; } +.document-drag-preview { + position: fixed; + top: -160px; + left: -160px; + z-index: var(--layer-notice); + display: flex; + width: 88px; + flex-direction: column; + align-items: center; + gap: 5px; + pointer-events: none; +} + +.document-drag-preview-page { + position: relative; + display: grid; + width: 48px; + height: 58px; + place-items: center; + border: 1px solid #b8c0cc; + border-radius: 2px; + background: #ffffff; + box-shadow: 0 3px 8px rgba(31, 35, 40, 0.2); + color: #52606f; +} + +.document-drag-preview-page::after { + position: absolute; + top: -1px; + right: -1px; + width: 13px; + height: 13px; + border-bottom: 1px solid #b8c0cc; + border-left: 1px solid #b8c0cc; + background: linear-gradient(225deg, var(--editor-bg) 49%, #ffffff 51%); + content: ''; +} + +.document-drag-preview-page > i { + margin-top: 2px; + color: var(--accent-color); + font-size: 22px; +} + +.document-drag-preview-badge { + position: absolute; + right: 3px; + bottom: 3px; + padding: 1px 3px; + border-radius: 3px; + background: var(--accent-color); + color: #ffffff; + font-size: 7px; + font-weight: 800; + letter-spacing: 0.03em; + line-height: 1.35; +} + +.document-drag-preview-label { + overflow: hidden; + max-width: 88px; + padding: 2px 5px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--toolbar-surface); + box-shadow: 0 2px 5px rgba(31, 35, 40, 0.14); + color: var(--text-color); + font-size: var(--ui-font-xs); + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + .document-tree-row.is-drop-target { background: color-mix(in srgb, var(--accent-color) 14%, var(--button-bg)); box-shadow: inset 0 0 0 1px var(--accent-color); diff --git a/sw.js b/sw.js index 75874576..ea480bd2 100644 --- a/sw.js +++ b/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'markdown-viewer-cache-v3.9.3-i18n2'; +const CACHE_NAME = 'markdown-viewer-cache-v3.9.3-images1'; // PERF-011: Split precache into critical (local files) and lazy (CDN libraries) // Critical assets are precached during SW install for instant offline startup diff --git a/wiki/Configuration.md b/wiki/Configuration.md index a8b581d4..71e0ea76 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -73,10 +73,10 @@ When running inside Neutralino, dynamic library URLs are rewritten to local `/li | Share URL warning ceiling | 32,000 characters | | Legacy share URL ceiling | 4,096 characters | | Server share threshold | 3,000 bytes | -| Stored Share Snapshot max content | 500,000 characters | +| Stored Share Snapshot max content | 8,000,000 characters | | Stored Share Snapshot TTL | 90 days | | Live Share max participants | 64 | -| Live Share max message | 1 MB | +| Live Share max message | 8 MB | | STL source limit | 2 MiB | | STL geometry limit | 300,000 vertices | @@ -86,7 +86,7 @@ The main preview path calls DOMPurify with additional tags and attributes needed - Additional tags include `mjx-container` and `input`. - Additional attributes include `id`, `class`, `style`, `align`, `type`, `checked`, `disabled`, `data-original-code`, `role`, `aria-labelledby`, and `aria-describedby`. -- Allowed URI schemes include HTTP(S), `mailto:`, `tel:`, `blob:`, relative URLs, and safe non-script values. +- Allowed URI schemes include HTTP(S), `mailto:`, `tel:`, `blob:`, relative URLs, safe non-script values, and base64 raster image data for AVIF, BMP, GIF, JPEG, PNG, and WebP. SVG data URLs remain blocked. Export paths use similar expanded sanitizer settings for SVG/math capture. Scripts and unsafe event handlers are still removed. diff --git a/wiki/Features.md b/wiki/Features.md index 2efdc5bc..d59a2064 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -231,10 +231,17 @@ Local file import: - Accepts `.md`, `.markdown`, and `text/markdown`. - Extension checks are case-insensitive. -- Dragging a file over the app shows a full-window drop overlay. +- Dragging files over the app shows a compact drop notice. Explorer document drags use a Markdown file preview, folders expand on hover, and the Explorer scrolls near its top and bottom edges. - The first 8 KB of a file are scanned for null bytes to avoid loading binary files as text. - Imported local files open in the active tab or a new tab depending on the action. +Image insertion: + +- Uploading from the image dialog, pasting from the clipboard, and dropping an image all use the same insertion pipeline. +- Small raster files are embedded directly; larger images are resized and converted to WebP with a bounded embedded size. +- Embedded image data is stored inside the Markdown, so it survives refreshes and is included in Share Snapshot and Live Share document content. +- Individual source images are limited to 25 MiB, and the app rejects an insertion before it would exceed a conservative browser-storage budget. + GitHub import: - Accepts `github.com/owner/repo`, `github.com/owner/repo/tree/ref/path`, `github.com/owner/repo/blob/ref/path`, and `raw.githubusercontent.com` file URLs. @@ -308,7 +315,7 @@ Storage behavior: - If the encoded legacy URL is too long, or if the Markdown is at least 3,000 bytes, the app stores the snapshot through `/api/share`. - Stored snapshots receive an id in `#id=...` form. - Stored snapshots are saved in Cloudflare KV for 90 days. -- The server accepts up to 500,000 characters per stored snapshot. +- The server accepts up to 8,000,000 characters per stored snapshot. - Snapshot ids are random 10-character values using a reduced alphabet and must match the app's id pattern. - Stored snapshot responses use `Cache-Control: no-store`. - The Share API allows CORS only for the production app, `null`, and `localhost`/`127.0.0.1` development origins; unsupported origins are rejected. @@ -352,7 +359,7 @@ Implementation: Limits: -- A live message can be at most 1 MB. +- A live message can be at most 8 MB so optimized embedded images can synchronize with the Markdown. - A live room can have at most 64 WebSocket participants. - Participant presence is considered stale after 45 seconds without updates. - Join waits up to 8 seconds for initial room state before showing an expired/unavailable room message. @@ -522,7 +529,7 @@ Security limitations: - Browser storage quotas can reject very large saved workspaces. - The GitHub importer shows a maximum of 30 Markdown files. -- Stored Share Snapshot content is limited to 500,000 characters. +- Stored Share Snapshot content is limited to 8,000,000 characters so optimized embedded images can travel with the Markdown. - STL source is limited to 2 MiB and parsed geometry to 300,000 vertices. - Legacy raster PDF and PNG exports can fail on extremely tall documents because canvas size and memory are browser-limited. - Remote renderer availability depends on third-party services and network conditions. diff --git a/wiki/Live-Share-Cloudflare.md b/wiki/Live-Share-Cloudflare.md index 2d8902cd..3a21982a 100644 --- a/wiki/Live-Share-Cloudflare.md +++ b/wiki/Live-Share-Cloudflare.md @@ -48,7 +48,7 @@ The Durable Object: | Limit | Value | | :--- | :--- | -| Max live message size | 1 MB | +| Max live message size | 8 MB | | Max WebSocket participants per room | 64 | | Participant stale timeout in client UI | 45 seconds | | Client join timeout | 8 seconds | diff --git a/wiki/Localization.md b/wiki/Localization.md index 50691962..6017c5e8 100644 --- a/wiki/Localization.md +++ b/wiki/Localization.md @@ -82,6 +82,7 @@ Add new keys to every locale when introducing new visible UI text. If a key is m ## Contributor Checklist +- Regenerate interface catalogs from the repository root with `node generate-ui-locales.mjs`. - Update `I18N_DICTS` for all supported locales. - Update desktop resources by running the desktop prepare step. - Check desktop and mobile menus. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index c6065485..3e629d47 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -136,7 +136,7 @@ Use Share Snapshot when you want to send a point-in-time copy. View only opens the document in preview mode and hides editing. Editable opens the copy in split mode so the recipient can edit their own local copy. It is not real-time collaboration. -Small documents are compressed into the URL hash as `#share=...`. Large documents, or documents whose encoded URL would be too long, are stored through `/api/share` and opened with `#id=...`. Stored snapshots use Cloudflare KV for up to 90 days and can contain up to 500,000 characters. They are bearer links: anyone with the URL can open the snapshot. The creator-side API response includes a deletion token; keep it separate from the share URL if you need to delete the stored record before expiry. +Small documents are compressed into the URL hash as `#share=...`. Large documents, or documents whose encoded URL would be too long, are stored through `/api/share` and opened with `#id=...`. Stored snapshots use Cloudflare KV for up to 90 days and can contain up to 8,000,000 characters, including optimized images embedded in the Markdown. They are bearer links: anyone with the URL can open the snapshot. The creator-side API response includes a deletion token; keep it separate from the share URL if you need to delete the stored record before expiry. Shared snapshot tabs are temporary and are not saved into the recipient's workspace. @@ -152,7 +152,7 @@ Use Live Share when you want a temporary real-time room. Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not store the document in KV or a database. The invite URL contains a room id, room secret, access role/capability, and title, not the full document body. The server authenticates host, editable, and view-only capabilities and filters message types by role. Markdown and Review data use separate Yjs documents, so view-only participants can synchronize comments and suggestions without being allowed to edit Markdown. -Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 1 MB live messages. +Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages so optimized embedded images can synchronize with the Markdown. ## Rendering Diagrams, Maps, Math, STL, and ABC Notation diff --git a/workers/live-room-worker.js b/workers/live-room-worker.js index b69677a2..b7f4ad04 100644 --- a/workers/live-room-worker.js +++ b/workers/live-room-worker.js @@ -1,4 +1,4 @@ -const MAX_MESSAGE_BYTES = 1024 * 1024; +const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; const MAX_PARTICIPANTS = 64; const AUTH_STORAGE_KEY = "live-room-auth-v1"; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{24,160}$/; From 22a2b929168fed6b698a457d4cf8826ef0d50776 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 22 Jul 2026 21:28:21 +0530 Subject: [PATCH 03/10] feat(editor): add managed short image links --- CHANGELOG.md | 2 +- README.md | 8 +- assets/i18n/de.json | 27 ++- assets/i18n/en.json | 25 ++- assets/i18n/es.json | 27 ++- assets/i18n/fr.json | 27 ++- .../i18n/generate-ui-locales.mjs | 6 +- assets/i18n/it.json | 27 ++- assets/i18n/ja.json | 27 ++- assets/i18n/ko.json | 27 ++- assets/i18n/pl.json | 27 ++- assets/i18n/pt.json | 27 ++- assets/i18n/ru.json | 27 ++- assets/i18n/tr.json | 27 ++- assets/i18n/tw.json | 27 ++- assets/i18n/uk.json | 27 ++- assets/i18n/zh.json | 27 ++- desktop-app/resources/index.html | 1 + desktop-app/resources/js/script.js | 175 ++++++++++++--- functions/api/image/[[id]].js | 201 ++++++++++++++++++ index.html | 1 + script.js | 175 ++++++++++++--- wiki/Configuration.md | 14 +- wiki/Contributing.md | 4 +- wiki/FAQ.md | 7 +- wiki/Features.md | 13 +- wiki/Installation.md | 11 +- wiki/Live-Share-Cloudflare.md | 4 +- wiki/Localization.md | 2 +- wiki/Usage-Guide.md | 4 +- 30 files changed, 851 insertions(+), 153 deletions(-) rename generate-ui-locales.mjs => assets/i18n/generate-ui-locales.mjs (99%) create mode 100644 functions/api/image/[[id]].js diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b08721..ce2c9369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Non-code commits (documentation, planning, README-only updates) are excluded. ## Unreleased -- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized, embedded, persistent, and available in Snapshot and Live Share documents. +- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized and stored behind short content-addressed links. Existing embedded raster images can be converted automatically after consent, and short links remain usable across refreshes, Snapshot, and Live Share. ## v3.9.3 diff --git a/README.md b/README.md index ad6127b4..8ddb3a1c 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. - Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, precise drag-and-drop moves, hover-to-expand folders, and edge auto-scrolling. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. -- Paste, upload, or drop image files to insert optimized image data directly into the Markdown at the editor cursor. Embedded images survive refreshes and travel with Share Snapshot and Live Share documents; very large collections may need external image URLs to stay within browser storage limits. +- Paste, upload, or drop image files to insert optimized, content-addressed HTTPS image links at the editor cursor. The app explains on first use that managed images are publicly retrievable by their unguessable link; links remain short across refreshes, Share Snapshot, Live Share, and Markdown export. Existing inline raster data can be converted after the same consent. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. @@ -234,8 +234,8 @@ Markdown-Viewer/ +-- styles.css # App and preview styles +-- preview-worker.js # Markdown preview worker +-- sw.js # Service worker cache behavior -+-- generate-ui-locales.mjs # Root-level interface locale generator +-- assets/ # App images and icons +| +-- i18n/ # Interface catalogs and locale generator +-- functions/ # Cloudflare Pages Functions +-- workers/ # Live Share Worker source +-- desktop-app/ # Neutralinojs desktop build @@ -273,14 +273,14 @@ Some advanced diagram engines use remote renderers such as PlantUML, Kroki, or m Markdown Viewer is not a cloud workspace. Normal typing, preview rendering, local file import, tab autosave, theme settings, and most exports happen on your device. No login is required, and the app does not implement analytics, telemetry, ads, or tracking cookies. -Network use is user-triggered for features such as GitHub import, remote diagram renderers, Share Snapshot, Live Share, CDN libraries, and external document assets. Private mode in Workspace settings keeps document content, workspace state, and review feedback session-only while editing and review tools remain available. For the full reference, read the [data handling summary](wiki/Features.md#data-handling-summary). +Network use is user-triggered for features such as consented managed-image upload, GitHub import, remote diagram renderers, Share Snapshot, Live Share, CDN libraries, and external document assets. Private mode in Workspace settings keeps document content, workspace state, and review feedback session-only while editing and review tools remain available. For the full reference, read the [data handling summary](wiki/Features.md#data-handling-summary). ## Security and Privacy Controls - Preview HTML is sanitized before insertion, and exported HTML includes a restrictive CSP plus SRI metadata for its external assets. - Secret Workspace derives a local encryption key from the user's password with PBKDF2-SHA-256 and encrypts its files and folder names with AES-GCM. The key is kept only for the unlocked browser session, and forgotten passwords cannot be recovered. - Cloudflare Pages deployments use `_headers` for CSP, clickjacking protection, referrer and permissions policies, and no-sniff protection; sensitive paths are redirected to 404 responses. -- Stored Share Snapshot API CORS is limited to the production app, `null`, and local development origins. Stored responses are `no-store`, and creators receive a deletion token from the API. +- Managed image and stored Share Snapshot API uploads are limited to the production app, previews, `null`, and local development origins. Managed images are public through unguessable immutable links; stored snapshot responses are `no-store`, and snapshot creators receive a deletion token from the API. - STL rendering rejects oversized sources, non-finite geometry, and excessive vertex counts before WebGL rendering. - The Neutralino desktop build removes the default `os.execCommand` exposure and keeps native APIs on an explicit allowlist. See the [security model](wiki/Features.md#security-model) and [configuration reference](wiki/Configuration.md#share-api). diff --git a/assets/i18n/de.json b/assets/i18n/de.json index d7edb357..062a955e 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -11,7 +11,6 @@ ", file location": ", Dateispeicherort", ", locked": ", gesperrt", ", or": ", oder", - ".md or .markdown files supported": ".md- oder .markdown-Dateien werden unterstützt", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; Höhe: 100 %; Anzeige:flex; align-items:center; justify-content:center; overflow:hidden;\">", "{{0}} characters": "{{0}} Zeichen", "{{0}} details go here.": "{{0}} Details finden Sie hier.", @@ -253,7 +252,6 @@ "Download started": "Download gestartet", "Download SVG": "SVG herunterladen", "Drag files to move": "Ziehen Sie Dateien zum Verschieben", - "Drop your Markdown files anywhere": "Legen Sie Ihre Markdown-Dateien irgendwo ab", "Duet Accord": "Duett-Akkord", "Duplicate": "Duplizieren", "Duplicate {{0}}": "Duplikat {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Falscher Zugriffsschlüssel oder unlesbare Secret Workspace-Daten.", "Live room disconnected": "Live-Raum nicht verbunden", "Maximum document limit reached": "Maximale Dokumentengrenze erreicht", - "Mermaid diagram": "Meerjungfrau-Diagramm", - "Mermaid diagram actions": "Meerjungfrau-Diagrammaktionen", "No documents match your search.": "Keine Dokumente entsprechen Ihrer Suche.", "Open or create a document to use editing tools.": "Öffnen oder erstellen Sie ein Dokument, um Bearbeitungswerkzeuge zu verwenden.", "Review item deleted.": "Bewertungselement gelöscht.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Dieser Freigabelink ist abgelaufen oder existiert nicht.", "This shared document is read-only for you.": "Dieses freigegebene Dokument ist für Sie schreibgeschützt.", "Your file is ready.": "Ihre Datei ist fertig.", - "Your files are ready.": "Ihre Dateien sind bereit." + "Your files are ready.": "Ihre Dateien sind bereit.", + "Collapse": "Reduzieren", + "converted to short links.": "in Kurzlinks umgewandelt.", + "Converting embedded images to short links.": "Eingebettete Bilder in Kurzlinks umwandeln.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Gerätebilder werden optimiert und hochgeladen, um einen kurzen öffentlichen Link zu erstellen. Jeder mit der Bild-URL kann es ansehen.", + "Drop Markdown files or images to add them.": "Legen Sie Markdown-Dateien oder Bilder ab, um sie hinzuzufügen.", + "Drop to import or insert": "Zum Importieren oder Einfügen ablegen", + "embedded image": "eingebettetes Bild", + "Embedded images could not be converted. Please try again.": "Eingebettete Bilder konnten nicht konvertiert werden. Bitte versuchen Sie es erneut.", + "expanded.": "erweitert.", + "for short links.": "für kurze Links.", + "Image conversion was cancelled because the document changed.": "Die Bildkonvertierung wurde abgebrochen, da sich das Dokument geändert hat.", + "Image insertion was cancelled because the document changed.": "Das Einfügen von Bildern wurde abgebrochen, da sich das Dokument geändert hat.", + "Image upload cancelled.": "Bild-Upload abgebrochen.", + "Images larger than 25MB cannot be inserted.": "Bilder, die größer als 25 MB sind, können nicht eingefügt werden.", + "Markdown files and images are supported": "Markdown Dateien und Bilder werden unterstützt", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Öffnen Sie eine bearbeitbare Markdown-Datei, bevor Sie Bilder einfügen.", + "The image could not be inserted.": "Das Bild konnte nicht eingefügt werden.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Um kurze dauerhafte Links zu erstellen, werden Bilder optimiert und in den öffentlichen Bildspeicher Markdown Viewer hochgeladen. Jeder mit einer Bild-URL kann es ansehen. Weitermachen?", + "uploaded and inserted with short links.": "hochgeladen und mit Kurzlinks eingefügt.", + "Uploading": "Hochladen" } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 3a559005..efa923b3 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -11,7 +11,6 @@ ", file location": ", file location", ", locked": ", locked", ", or": ", or", - ".md or .markdown files supported": ".md or .markdown files supported", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">", "{{0}} and {{1}}": "{{0}} and {{1}}", "{{0}} characters": "{{0}} characters", @@ -187,6 +186,7 @@ "Collaborators can edit this document.": "Collaborators can edit this document.", "Collaborators can view updates but cannot edit.": "Collaborators can view updates but cannot edit.", "Collaborators can view without editing.": "Collaborators can view without editing.", + "Collapse": "Collapse", "Collapse all folders": "Collapse all folders", "Collapse Workspace": "Collapse Workspace", "Column count": "Column count", @@ -204,6 +204,8 @@ "Confirm action": "Confirm action", "Connection lost": "Connection lost", "Content structure": "Content structure", + "converted to short links.": "converted to short links.", + "Converting embedded images to short links.": "Converting embedded images to short links.", "Copied": "Copied", "Copied!": "Copied!", "Copy": "Copy", @@ -256,6 +258,7 @@ "Destination": "Destination", "details go here.": "details go here.", "Developed and maintained by": "Developed and maintained by", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.", "Diagram and more": "Diagram and more", "Diagram Categories": "Diagram Categories", "Diagram source code...": "Diagram source code...", @@ -281,7 +284,8 @@ "Download started": "Download started", "Download SVG": "Download SVG", "Drag files to move": "Drag files to move", - "Drop your Markdown files anywhere": "Drop your Markdown files anywhere", + "Drop Markdown files or images to add them.": "Drop Markdown files or images to add them.", + "Drop to import or insert": "Drop to import or insert", "Duet Accord": "Duet Accord", "duplicate": "duplicate", "Duplicate": "Duplicate", @@ -293,6 +297,8 @@ "Edit suggestion": "Edit suggestion", "Editing": "Editing", "Editing and preview enabled.": "Editing and preview enabled.", + "embedded image": "embedded image", + "Embedded images could not be converted. Please try again.": "Embedded images could not be converted. Please try again.", "Emoji list": "Emoji list", "Emoji shortcode": "Emoji shortcode", "Emojis loaded.": "Emojis loaded.", @@ -325,6 +331,7 @@ "Expand all folders": "Expand all folders", "Expand or collapse all is available in All files": "Expand or collapse all is available in All files", "Expand Secret Workspace": "Expand Secret Workspace", + "expanded.": "expanded.", "Explore features with an example": "Explore features with an example", "Explorer": "Explorer", "Explorer item": "Explorer item", @@ -402,6 +409,7 @@ "folders": "folders", "Folders stay one level deep inside a workspace.": "Folders stay one level deep inside a workspace.", "Folk & Lyrics": "Folk & Lyrics", + "for short links.": "for short links.", "for this block.": "for this block.", "Forgot access key? Reset Secret Workspace": "Forgot access key? Reset Secret Workspace", "Fresh Tomato": "Fresh Tomato", @@ -450,9 +458,13 @@ "HTML export failed:": "HTML export failed:", "Image": "Image", "Image (.png)": "Image (.png)", + "Image conversion was cancelled because the document changed.": "Image conversion was cancelled because the document changed.", "Image description": "Image description", "Image generation progress": "Image generation progress", + "Image insertion was cancelled because the document changed.": "Image insertion was cancelled because the document changed.", + "Image upload cancelled.": "Image upload cancelled.", "Image URL": "Image URL", + "Images larger than 25MB cannot be inserted.": "Images larger than 25MB cannot be inserted.", "Implement live preview with GitHub styling": "Implement live preview with GitHub styling", "Import": "Import", "Import complete": "Import complete", @@ -538,6 +550,7 @@ "Markdown document": "Markdown document", "Markdown editor input with live preview": "Markdown editor input with live preview", "Markdown file import progress": "Markdown file import progress", + "Markdown files and images are supported": "Markdown files and images are supported", "Markdown formatting toolbar for plain-text editing": "Markdown formatting toolbar for plain-text editing", "Markdown Görüntüleyici": "Markdown Görüntüleyici", "Markdown preview only": "Markdown preview only", @@ -559,11 +572,10 @@ "Maximum document limit reached": "Maximum document limit reached", "Maximum document limit reached.": "Maximum document limit reached.", "Maximum of": "Maximum of", + "MD": "MD", "Menu": "Menu", "Mermaid blocks only": "Mermaid blocks only", "Mermaid could not be rendered. Check the diagram syntax and retry.": "Mermaid could not be rendered. Check the diagram syntax and retry.", - "Mermaid diagram": "Mermaid diagram", - "Mermaid diagram actions": "Mermaid diagram actions", "meter, composed by": "meter, composed by", "Min Read": "Min Read", "Mind Maps": "Mind Maps", @@ -632,6 +644,7 @@ "open": "open", "Open": "Open", "Open a file or repository": "Open a file or repository", + "Open an editable Markdown file before inserting images.": "Open an editable Markdown file before inserting images.", "Open comments and suggestions": "Open comments and suggestions", "Open diagram viewer": "Open diagram viewer", "Open diagram viewer with zoom and pan controls": "Open diagram viewer with zoom and pan controls", @@ -910,6 +923,7 @@ "Text style": "Text style", "The command-line file could not be opened because the": "The command-line file could not be opened because the", "The file could not be moved because the destination could not be saved.": "The file could not be moved because the destination could not be saved.", + "The image could not be inserted.": "The image could not be inserted.", "The live room could not be joined. Please check the invite link or connection.": "The live room could not be joined. Please check the invite link or connection.", "The Live Share room could not be opened because the": "The Live Share room could not be opened because the", "The provided URL does not point to a Markdown file.": "The provided URL does not point to a Markdown file.", @@ -938,6 +952,7 @@ "Title": "Title", "Title case": "Title case", "to build lists, and triple backticks for code blocks.": "to build lists, and triple backticks for code blocks.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?", "to stay within the": "to stay within the", "Toggle Dock Mode": "Toggle Dock Mode", "Toggle Floating Mode": "Toggle Floating Mode", @@ -963,6 +978,8 @@ "Unlock workspace": "Unlock workspace", "Unlocking…": "Unlocking…", "Upload from Device": "Upload from Device", + "uploaded and inserted with short links.": "uploaded and inserted with short links.", + "Uploading": "Uploading", "UPPERCASE": "UPPERCASE", "Use": "Use", "Use at least 8 characters.": "Use at least 8 characters.", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 3b3f411c..adea6c54 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -11,7 +11,6 @@ ", file location": ", ubicación del archivo", ", locked": ", bloqueado", ", or": ", o", - ".md or .markdown files supported": "Se admiten archivos .md o .markdown", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" estilo=\"ancho:100%; altura: 100%; pantalla:flexible; alinear elementos:centro; justificar-contenido:centro; desbordamiento: oculto;\">", "{{0}} characters": "{{0}} caracteres", "{{0}} details go here.": "{{0}} detalles van aquí.", @@ -253,7 +252,6 @@ "Download started": "Descarga iniciada", "Download SVG": "Descargar SVG", "Drag files to move": "Arrastra archivos para moverlos", - "Drop your Markdown files anywhere": "Suelta tus archivos Markdown en cualquier lugar", "Duet Accord": "Acuerdo a dúo", "Duplicate": "Duplicar", "Duplicate {{0}}": "Duplicar {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Clave de acceso incorrecta o datos del Espacio de trabajo secreto ilegibles.", "Live room disconnected": "Sala en vivo desconectada", "Maximum document limit reached": "Límite máximo de documentos alcanzado", - "Mermaid diagram": "Diagrama de sirena", - "Mermaid diagram actions": "Acciones del diagrama de sirena", "No documents match your search.": "Ningún documento coincide con tu búsqueda.", "Open or create a document to use editing tools.": "Abre o crea un documento para usar herramientas de edición.", "Review item deleted.": "Artículo de revisión eliminado.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Este enlace para compartir ha caducado o no existe.", "This shared document is read-only for you.": "Este documento compartido es de solo lectura para ti.", "Your file is ready.": "Tu archivo está listo.", - "Your files are ready.": "Tus archivos están listos." + "Your files are ready.": "Tus archivos están listos.", + "Collapse": "Colapso", + "converted to short links.": "convertido a enlaces cortos.", + "Converting embedded images to short links.": "Conversión de imágenes incrustadas en enlaces cortos.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Las imágenes del dispositivo se optimizan y se cargan para crear un enlace público breve. Cualquiera que tenga la URL de la imagen puede verla.", + "Drop Markdown files or images to add them.": "Suelta Markdown archivos o imágenes para agregarlos.", + "Drop to import or insert": "Soltar para importar o insertar", + "embedded image": "imagen incrustada", + "Embedded images could not be converted. Please try again.": "Las imágenes incrustadas no se pudieron convertir. Por favor inténtalo de nuevo.", + "expanded.": "ampliado.", + "for short links.": "para enlaces cortos.", + "Image conversion was cancelled because the document changed.": "La conversión de imagen se canceló porque el documento cambió.", + "Image insertion was cancelled because the document changed.": "La inserción de la imagen se canceló porque el documento cambió.", + "Image upload cancelled.": "Carga de imagen cancelada.", + "Images larger than 25MB cannot be inserted.": "No se pueden insertar imágenes de más de 25 MB.", + "Markdown files and images are supported": "Se admiten archivos e imágenes Markdown", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Abra un archivo Markdown editable antes de insertar imágenes.", + "The image could not be inserted.": "No se pudo insertar la imagen.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Para crear enlaces cortos persistentes, las imágenes se optimizan y se cargan en el almacenamiento público de imágenes Markdown Viewer. Cualquiera que tenga una URL de imagen puede verla. ¿Continuar?", + "uploaded and inserted with short links.": "subido e insertado con enlaces cortos.", + "Uploading": "Subiendo" } diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 94b27736..24564a3e 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -11,7 +11,6 @@ ", file location": ", emplacement du fichier", ", locked": ", verrouillé", ", or": ", ou", - ".md or .markdown files supported": "Fichiers .md ou .markdown pris en charge", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"largeur:100%; hauteur : 100 % ; affichage:flex; align-items:center; justifier-content:center; débordement:caché;\">", "{{0}} characters": "{{0}} caractères\nLes détails de", "{{0}} details go here.": "{{0}} sont ici.", @@ -253,7 +252,6 @@ "Download started": "Téléchargement démarré", "Download SVG": "Télécharger SVG", "Drag files to move": "Faites glisser les fichiers pour les déplacer", - "Drop your Markdown files anywhere": "Déposez vos fichiers Markdown n'importe où", "Duet Accord": "Accord en duo", "Duplicate": "Dupliquer", "Duplicate {{0}}": "Dupliquer {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Clé d'accès incorrecte ou données de l'espace de travail secret illisibles.", "Live room disconnected": "Salle en direct déconnectée", "Maximum document limit reached": "Limite maximale de documents atteinte", - "Mermaid diagram": "Diagramme de la sirène", - "Mermaid diagram actions": "Actions du diagramme de sirène", "No documents match your search.": "Aucun document ne correspond à votre recherche.", "Open or create a document to use editing tools.": "Ouvrez ou créez un document pour utiliser les outils d'édition.", "Review item deleted.": "Élément de révision supprimé.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Ce lien de partage a expiré ou n'existe pas.", "This shared document is read-only for you.": "Ce document partagé est en lecture seule pour vous.", "Your file is ready.": "Votre fichier est prêt.", - "Your files are ready.": "Vos fichiers sont prêts." + "Your files are ready.": "Vos fichiers sont prêts.", + "Collapse": "Réduire", + "converted to short links.": "converti en liens courts.", + "Converting embedded images to short links.": "Conversion d'images intégrées en liens courts.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Les images des appareils sont optimisées et téléchargées pour créer un court lien public. Toute personne disposant de l’URL de l’image peut la voir.", + "Drop Markdown files or images to add them.": "Déposez les fichiers ou images Markdown pour les ajouter.", + "Drop to import or insert": "Déposer pour importer ou insérer", + "embedded image": "image intégrée", + "Embedded images could not be converted. Please try again.": "Les images intégrées n'ont pas pu être converties. Veuillez réessayer.", + "expanded.": "développé.", + "for short links.": "pour les liens courts.", + "Image conversion was cancelled because the document changed.": "La conversion de l'image a été annulée car le document a été modifié.", + "Image insertion was cancelled because the document changed.": "L'insertion de l'image a été annulée car le document a été modifié.", + "Image upload cancelled.": "Téléchargement de l'image annulé.", + "Images larger than 25MB cannot be inserted.": "Les images de plus de 25 Mo ne peuvent pas être insérées.", + "Markdown files and images are supported": "Les fichiers et images Markdown sont pris en charge", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Ouvrez un fichier Markdown modifiable avant d'insérer des images.", + "The image could not be inserted.": "L'image n'a pas pu être insérée.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Pour créer des liens persistants courts, les images sont optimisées et téléchargées vers le stockage d'images public Markdown Viewer. Toute personne disposant d’une URL d’image peut la voir. Continuer?", + "uploaded and inserted with short links.": "téléchargé et inséré avec des liens courts.", + "Uploading": "Téléchargement" } diff --git a/generate-ui-locales.mjs b/assets/i18n/generate-ui-locales.mjs similarity index 99% rename from generate-ui-locales.mjs rename to assets/i18n/generate-ui-locales.mjs index c73168ff..5a6b13cd 100644 --- a/generate-ui-locales.mjs +++ b/assets/i18n/generate-ui-locales.mjs @@ -4,9 +4,9 @@ import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { chromium } from '@playwright/test'; -const ROOT = new URL('./', import.meta.url); +const ROOT = new URL('../../', import.meta.url); const ROOT_PATH = fileURLToPath(ROOT); -const OUTPUT_DIR = new URL('./assets/i18n/', import.meta.url); +const OUTPUT_DIR = new URL('./', import.meta.url); const PORT = 4197; const HOST = '127.0.0.1'; const FORCE = process.argv.includes('--force'); @@ -414,7 +414,7 @@ async function main() { return; } const domStrings = await collectDomStrings(); - const scriptSource = await readFile(new URL('./script.js', import.meta.url), 'utf8'); + const scriptSource = await readFile(new URL('../../script.js', import.meta.url), 'utf8'); const allStrings = new Set([...domStrings, ...extractScriptStrings(scriptSource), ...EXTRA_STRINGS]); const strings = Array.from(allStrings).map(normalize).filter(isTranslatable).sort((a, b) => a.localeCompare(b)); diff --git a/assets/i18n/it.json b/assets/i18n/it.json index 70761e17..a752d846 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -11,7 +11,6 @@ ", file location": ", percorso del file", ", locked": ", bloccato", ", or": ", o", - ".md or .markdown files supported": "Sono supportati i file .md o .markdown", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" stile=\"larghezza:100%; altezza: 100%; display:flessibile; allinea-elementi:centro; giustifica-contenuto:centro; overflow:nascosto;\">", "{{0}} characters": "{{0}} caratteri", "{{0}} details go here.": "{{0}} dettagli vanno qui.", @@ -253,7 +252,6 @@ "Download started": "Download avviato", "Download SVG": "Scarica SVG", "Drag files to move": "Trascina i file da spostare", - "Drop your Markdown files anywhere": "Trascina i tuoi file Markdown ovunque", "Duet Accord": "Accordo Duetto", "Duplicate": "Duplicato", "Duplicate {{0}}": "Duplicato {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Chiave di accesso errata o dati Secret Workspace illeggibili.", "Live room disconnected": "Sala live disconnessa", "Maximum document limit reached": "Limite massimo di documenti raggiunto", - "Mermaid diagram": "Schema della sirena", - "Mermaid diagram actions": "Azioni del diagramma della sirena", "No documents match your search.": "Nessun documento corrisponde alla tua ricerca.", "Open or create a document to use editing tools.": "Apri o crea un documento per utilizzare gli strumenti di modifica.", "Review item deleted.": "Elemento di revisione eliminato.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Questo collegamento di condivisione è scaduto o non esiste.", "This shared document is read-only for you.": "Questo documento condiviso è di sola lettura per te.", "Your file is ready.": "Il tuo file è pronto.", - "Your files are ready.": "I tuoi file sono pronti." + "Your files are ready.": "I tuoi file sono pronti.", + "Collapse": "Comprimi", + "converted to short links.": "convertito in collegamenti brevi.", + "Converting embedded images to short links.": "Conversione di immagini incorporate in collegamenti brevi.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Le immagini del dispositivo vengono ottimizzate e caricate per creare un breve collegamento pubblico. Chiunque abbia l'URL dell'immagine può visualizzarla.", + "Drop Markdown files or images to add them.": "Rilascia Markdown file o immagini per aggiungerli.", + "Drop to import or insert": "Rilascia per importare o inserire", + "embedded image": "immagine incorporata", + "Embedded images could not be converted. Please try again.": "Impossibile convertire le immagini incorporate. Per favore riprova.", + "expanded.": "ampliato.", + "for short links.": "per i collegamenti brevi.", + "Image conversion was cancelled because the document changed.": "La conversione dell'immagine è stata annullata perché il documento è cambiato.", + "Image insertion was cancelled because the document changed.": "L'inserimento dell'immagine è stato annullato perché il documento è cambiato.", + "Image upload cancelled.": "Caricamento immagine annullato.", + "Images larger than 25MB cannot be inserted.": "Non è possibile inserire immagini di dimensioni superiori a 25 MB.", + "Markdown files and images are supported": "Sono supportati file e immagini Markdown", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Apri un file modificabile Markdown prima di inserire le immagini.", + "The image could not be inserted.": "Impossibile inserire l'immagine.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Per creare collegamenti brevi e persistenti, le immagini vengono ottimizzate e caricate nell'archivio pubblico di immagini Markdown Viewer. Chiunque abbia un URL di immagine può visualizzarlo. Continuare?", + "uploaded and inserted with short links.": "caricato e inserito con collegamenti brevi.", + "Uploading": "Caricamento" } diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index a687f24a..6d851555 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -11,7 +11,6 @@ ", file location": "、ファイルの場所", ", locked": "、ロックされています", ", or": "、または", - ".md or .markdown files supported": ".md または .markdown ファイルをサポート", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%;高さ:100%;ディスプレイ:フレックス;整列項目:中央;コンテンツの位置揃え:中央;オーバーフロー:非表示;\">", "{{0}} characters": "{{0}} 文字", "{{0}} details go here.": "{{0}} の詳細はこちらをご覧ください。", @@ -253,7 +252,6 @@ "Download started": "ダウンロード開始", "Download SVG": "SVG をダウンロード", "Drag files to move": "ファイルをドラッグして移動します", - "Drop your Markdown files anywhere": "Markdown ファイルを任意の場所にドロップします", "Duet Accord": "デュエットアコード", "Duplicate": "重複", "Duplicate {{0}}": "重複 {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "アクセス キーが間違っているか、シークレット ワークスペース データを読み取ることができません。", "Live room disconnected": "ライブルームが切断されました", "Maximum document limit reached": "最大ドキュメント制限に達しました", - "Mermaid diagram": "マーメイドダイアグラム", - "Mermaid diagram actions": "マーメイドダイアグラムアクション", "No documents match your search.": "検索に一致するドキュメントはありません。", "Open or create a document to use editing tools.": "編集ツールを使用するには、ドキュメントを開くか作成します。", "Review item deleted.": "レビュー項目が削除されました。\n【[MV3]】レビュー品再開しました。", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "この共有リンクは有効期限が切れているか、存在しません。", "This shared document is read-only for you.": "この共有ドキュメントは読み取り専用です。", "Your file is ready.": "ファイルの準備ができました。", - "Your files are ready.": "ファイルの準備ができました。" + "Your files are ready.": "ファイルの準備ができました。", + "Collapse": "崩壊", + "converted to short links.": "は短いリンクに変換されました。", + "Converting embedded images to short links.": "埋め込まれた画像を短いリンクに変換します。", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "デバイス画像は最適化され、短いパブリック リンクを作成するためにアップロードされます。画像のURLがわかれば誰でも閲覧できます。", + "Drop Markdown files or images to add them.": "Markdown ファイルまたは画像をドロップして追加します。", + "Drop to import or insert": "ドロップしてインポートまたは挿入します", + "embedded image": "埋め込み画像", + "Embedded images could not be converted. Please try again.": "埋め込み画像を変換できませんでした。もう一度試してください。", + "expanded.": "を展開しました。", + "for short links.": "短いリンク。", + "Image conversion was cancelled because the document changed.": "ドキュメントが変更されたため、画像変換がキャンセルされました。", + "Image insertion was cancelled because the document changed.": "ドキュメントが変更されたため、画像の挿入はキャンセルされました。", + "Image upload cancelled.": "画像のアップロードがキャンセルされました。\n[[MV13]] 25MBを超える画像は挿入できません。", + "Images larger than 25MB cannot be inserted.": "Images larger than 25MB cannot be inserted.", + "Markdown files and images are supported": "Markdown ファイルと画像がサポートされています", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "画像を挿入する前に、編集可能な Markdown ファイルを開いてください。", + "The image could not be inserted.": "画像を挿入できませんでした。", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "短い永続リンクを作成するために、画像が最適化され、Markdown Viewer パブリック画像ストレージにアップロードされます。画像の URL を知っている人は誰でも閲覧できます。続く?", + "uploaded and inserted with short links.": "がアップロードされ、短いリンクが挿入されました。", + "Uploading": "アップロード中" } diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index 1f601ac7..96c74b39 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -11,7 +11,6 @@ ", file location": ", 파일 위치", ", locked": ", 잠김", ", or": "또는", - ".md or .markdown files supported": ".md 또는 .markdown 파일 지원", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" 스타일=\"너비:100%; 높이:100%; 표시:플렉스; 항목 정렬:가운데; 내용 정당화:중심; 오버플로:숨김;\">", "{{0}} characters": "{{0}}자", "{{0}} details go here.": "{{0}} 세부정보는 여기에서 확인하세요.", @@ -253,7 +252,6 @@ "Download started": "다운로드가 시작되었습니다", "Download SVG": "SVG 다운로드", "Drag files to move": "이동할 파일을 드래그하세요", - "Drop your Markdown files anywhere": "Markdown 파일을 어디에나 드롭하세요", "Duet Accord": "듀엣 어코드", "Duplicate": "중복", "Duplicate {{0}}": "중복 {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "액세스 키가 잘못되었거나 Secret Workspace 데이터를 읽을 수 없습니다.", "Live room disconnected": "라이브룸 연결이 끊겼습니다", "Maximum document limit reached": "최대 문서 한도에 도달했습니다.", - "Mermaid diagram": "인어 그림", - "Mermaid diagram actions": "인어 다이어그램 액션", "No documents match your search.": "검색어와 일치하는 문서가 없습니다.", "Open or create a document to use editing tools.": "편집 도구를 사용하려면 문서를 열거나 생성하세요.", "Review item deleted.": "리뷰 항목이 삭제되었습니다.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "이 공유 링크는 만료되었거나 존재하지 않습니다.", "This shared document is read-only for you.": "이 공유 문서는 읽기 전용입니다.", "Your file is ready.": "파일이 준비되었습니다.", - "Your files are ready.": "파일이 준비되었습니다." + "Your files are ready.": "파일이 준비되었습니다.", + "Collapse": "접기", + "converted to short links.": "이 짧은 링크로 변환되었습니다.", + "Converting embedded images to short links.": "삽입된 이미지를 짧은 링크로 변환하는 중입니다.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "장치 이미지가 최적화되어 업로드되어 짧은 공개 링크가 생성됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다.", + "Drop Markdown files or images to add them.": "Markdown 파일이나 이미지를 드롭하여 추가하세요.", + "Drop to import or insert": "가져오기 또는 삽입하려면 드롭하세요.", + "embedded image": "삽입된 이미지", + "Embedded images could not be converted. Please try again.": "삽입된 이미지를 변환할 수 없습니다. 다시 시도해 주세요.", + "expanded.": "이 확장되었습니다.", + "for short links.": "짧은 링크용.", + "Image conversion was cancelled because the document changed.": "문서가 변경되어 이미지 변환이 취소되었습니다.", + "Image insertion was cancelled because the document changed.": "문서가 변경되어 이미지 삽입이 취소되었습니다.", + "Image upload cancelled.": "이미지 업로드가 취소되었습니다.", + "Images larger than 25MB cannot be inserted.": "25MB보다 큰 이미지는 삽입할 수 없습니다.", + "Markdown files and images are supported": "Markdown 파일 및 이미지가 지원됩니다.", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "이미지를 삽입하기 전에 편집 가능한 Markdown 파일을 엽니다.", + "The image could not be inserted.": "이미지를 삽입할 수 없습니다.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "짧은 영구 링크를 생성하기 위해 이미지가 최적화되어 Markdown Viewer 공개 이미지 저장소에 업로드됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다. 계속하다?", + "uploaded and inserted with short links.": "짧은 링크와 함께 업로드 및 삽입되었습니다.", + "Uploading": "업로드 중" } diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index 9e0e1235..f37f3aef 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -11,7 +11,6 @@ ", file location": ", lokalizacja pliku", ", locked": ", zablokowane", ", or": "lub", - ".md or .markdown files supported": "Obsługiwane pliki .md lub .markdown", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; wysokość:100%; wyświetlacz:elastyczny; wyrównaj elementy: środek; justify-content:center; przepełnienie:ukryte;\">", "{{0}} characters": "{{0}} znaków", "{{0}} details go here.": "{{0}} szczegóły znajdują się tutaj.", @@ -253,7 +252,6 @@ "Download started": "Pobieranie rozpoczęte", "Download SVG": "Pobierz plik SVG", "Drag files to move": "Przeciągnij pliki, aby je przenieść", - "Drop your Markdown files anywhere": "Upuść swoje pliki Markdown gdziekolwiek", "Duet Accord": "Umowa duetu", "Duplicate": "Duplikat", "Duplicate {{0}}": "Duplikat {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Nieprawidłowy klucz dostępu lub nieczytelne dane tajnego obszaru roboczego.", "Live room disconnected": "Pokój na żywo został odłączony", "Maximum document limit reached": "Osiągnięto maksymalny limit dokumentów", - "Mermaid diagram": "Schemat syreny", - "Mermaid diagram actions": "Działania na diagramie syreny", "No documents match your search.": "Żaden dokument nie pasuje do Twojego wyszukiwania.", "Open or create a document to use editing tools.": "Otwórz lub utwórz dokument, aby skorzystać z narzędzi do edycji.", "Review item deleted.": "Usunięto element recenzji.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Ten link do udostępniania wygasł lub nie istnieje.", "This shared document is read-only for you.": "Ten udostępniony dokument jest dla Ciebie tylko do odczytu.", "Your file is ready.": "Twój plik jest gotowy.", - "Your files are ready.": "Twoje pliki są gotowe." + "Your files are ready.": "Twoje pliki są gotowe.", + "Collapse": "Zwiń", + "converted to short links.": "przekonwertowane na krótkie linki.", + "Converting embedded images to short links.": "Konwersja osadzonych obrazów na krótkie linki.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Obrazy urządzeń są optymalizowane i przesyłane w celu utworzenia krótkiego łącza publicznego. Każdy, kto zna adres URL obrazu, może go wyświetlić.", + "Drop Markdown files or images to add them.": "Usuń Markdown plików lub obrazów, aby je dodać.", + "Drop to import or insert": "Upuść, aby zaimportować lub wstawić", + "embedded image": "osadzony obraz", + "Embedded images could not be converted. Please try again.": "Nie można przekonwertować osadzonych obrazów. Spróbuj ponownie.", + "expanded.": "rozwinięte.", + "for short links.": "dla krótkich linków.", + "Image conversion was cancelled because the document changed.": "Konwersja obrazu została anulowana z powodu zmiany dokumentu.", + "Image insertion was cancelled because the document changed.": "Wstawianie obrazu zostało anulowane, ponieważ dokument się zmienił.", + "Image upload cancelled.": "Przesyłanie obrazu zostało anulowane.", + "Images larger than 25MB cannot be inserted.": "Nie można wstawiać obrazów większych niż 25 MB.", + "Markdown files and images are supported": "Markdown obsługiwane są pliki i obrazy", + "MD": "Lek", + "Open an editable Markdown file before inserting images.": "Otwórz edytowalny plik Markdown przed wstawieniem obrazów.", + "The image could not be inserted.": "Nie można wstawić obrazu.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Aby utworzyć krótkie, trwałe łącza, obrazy są optymalizowane i przesyłane do publicznego magazynu obrazów Markdown Viewer. Każdy, kto ma adres URL obrazu, może go wyświetlić. Kontynuować?", + "uploaded and inserted with short links.": "przesłano i wstawiono z krótkimi linkami.", + "Uploading": "Przesyłanie" } diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index c8731258..f8f97b9e 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -11,7 +11,6 @@ ", file location": ", localização do arquivo", ", locked": ", bloqueado", ", or": "ou", - ".md or .markdown files supported": "Arquivos .md ou .markdown suportados", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\"estilo=\"largura:100%; altura:100%; exibição:flexível; alinhar itens: centro; justificar-conteúdo:centro; estouro: oculto;\">", "{{0}} characters": "{{0}} caracteres", "{{0}} details go here.": "{{0}} detalhes aqui.", @@ -253,7 +252,6 @@ "Download started": "Download iniciado", "Download SVG": "Baixar SVG", "Drag files to move": "Arraste os arquivos para mover", - "Drop your Markdown files anywhere": "Solte seus arquivos Markdown em qualquer lugar", "Duet Accord": "Acordo de dueto", "Duplicate": "Duplicado", "Duplicate {{0}}": "Duplicar {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Chave de acesso incorreta ou dados ilegíveis do Secret Workspace.", "Live room disconnected": "Sala ao vivo desconectada", "Maximum document limit reached": "Limite máximo de documentos atingido", - "Mermaid diagram": "Diagrama de sereia", - "Mermaid diagram actions": "Ações do diagrama sereia", "No documents match your search.": "Nenhum documento corresponde à sua pesquisa.", "Open or create a document to use editing tools.": "Abra ou crie um documento para usar ferramentas de edição.", "Review item deleted.": "Item de revisão excluído.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Este link de compartilhamento expirou ou não existe.", "This shared document is read-only for you.": "Este documento compartilhado é somente leitura para você.", "Your file is ready.": "Seu arquivo está pronto.", - "Your files are ready.": "Seus arquivos estão prontos." + "Your files are ready.": "Seus arquivos estão prontos.", + "Collapse": "Recolher", + "converted to short links.": "convertido em links curtos.", + "Converting embedded images to short links.": "Convertendo imagens incorporadas em links curtos.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "As imagens do dispositivo são otimizadas e carregadas para criar um link público curto. Qualquer pessoa com o URL da imagem pode visualizá-la.", + "Drop Markdown files or images to add them.": "Solte Markdown arquivos ou imagens para adicioná-los.", + "Drop to import or insert": "Solte para importar ou inserir", + "embedded image": "imagem incorporada", + "Embedded images could not be converted. Please try again.": "Não foi possível converter imagens incorporadas. Por favor, tente novamente.", + "expanded.": "expandido.", + "for short links.": "para links curtos.", + "Image conversion was cancelled because the document changed.": "A conversão da imagem foi cancelada porque o documento foi alterado.", + "Image insertion was cancelled because the document changed.": "A inserção da imagem foi cancelada porque o documento foi alterado.", + "Image upload cancelled.": "Upload de imagem cancelado.", + "Images larger than 25MB cannot be inserted.": "Imagens maiores que 25 MB não podem ser inseridas.", + "Markdown files and images are supported": "Markdown arquivos e imagens são suportados", + "MD": "Médico", + "Open an editable Markdown file before inserting images.": "Abra um arquivo Markdown editável antes de inserir imagens.", + "The image could not be inserted.": "Não foi possível inserir a imagem.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Para criar links curtos e persistentes, as imagens são otimizadas e enviadas para o armazenamento público de imagens Markdown Viewer. Qualquer pessoa com um URL de imagem pode visualizá-la. Continuar?", + "uploaded and inserted with short links.": "carregado e inserido com links curtos.", + "Uploading": "Enviando" } diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index 4750b70b..e9521aff 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -11,7 +11,6 @@ ", file location": ", расположение файла", ", locked": ", заблокировано", ", or": "или", - ".md or .markdown files supported": "Поддерживаются файлы .md или .markdown", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; высота: 100%; дисплей: гибкий; выровнять-элементы: по центру; оправдание-содержание: центр; переполнение: скрыто;\">", "{{0}} characters": "{{0}} символов", "{{0}} details go here.": "Подробности о {{0}} здесь.", @@ -253,7 +252,6 @@ "Download started": "Загрузка началась", "Download SVG": "Скачать SVG", "Drag files to move": "Перетащите файлы для перемещения", - "Drop your Markdown files anywhere": "Перетащите файлы Markdown куда угодно.", "Duet Accord": "Дуэтный аккорд", "Duplicate": "Дубликат", "Duplicate {{0}}": "Дубликат {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Неверный ключ доступа или нечитаемые данные секретной рабочей области.", "Live room disconnected": "Комната прямого эфира отключена", "Maximum document limit reached": "Достигнут максимальный лимит документов", - "Mermaid diagram": "Схема русалки", - "Mermaid diagram actions": "Действия на диаграмме русалки", "No documents match your search.": "Нет документов, соответствующих вашему запросу.", "Open or create a document to use editing tools.": "Откройте или создайте документ, чтобы использовать инструменты редактирования.", "Review item deleted.": "Элемент обзора удален.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Срок действия этой ссылки истек или она не существует.", "This shared document is read-only for you.": "Этот общий документ доступен вам только для чтения.", "Your file is ready.": "Ваш файл готов.", - "Your files are ready.": "Ваши файлы готовы." + "Your files are ready.": "Ваши файлы готовы.", + "Collapse": "Свернуть", + "converted to short links.": "преобразовано в короткие ссылки.", + "Converting embedded images to short links.": "Преобразование встроенных изображений в короткие ссылки.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Изображения устройств оптимизируются и загружаются для создания короткой общедоступной ссылки. Любой, у кого есть URL-адрес изображения, может просмотреть его.", + "Drop Markdown files or images to add them.": "Перетащите Markdown файлы или изображения, чтобы добавить их.", + "Drop to import or insert": "Отбросьте, чтобы импортировать или вставить", + "embedded image": "встроенное изображение", + "Embedded images could not be converted. Please try again.": "Не удалось преобразовать встроенные изображения. Пожалуйста, попробуйте еще раз.", + "expanded.": "расширено.", + "for short links.": "для коротких ссылок.", + "Image conversion was cancelled because the document changed.": "Преобразование изображения было отменено, поскольку документ изменился.", + "Image insertion was cancelled because the document changed.": "Вставка изображения была отменена, поскольку документ изменился.", + "Image upload cancelled.": "Загрузка изображения отменена.", + "Images larger than 25MB cannot be inserted.": "Невозможно вставить изображения размером более 25 МБ.", + "Markdown files and images are supported": "Поддерживаются файлы и изображения Markdown", + "MD": "Доктор медицины", + "Open an editable Markdown file before inserting images.": "Прежде чем вставлять изображения, откройте редактируемый файл Markdown.", + "The image could not be inserted.": "Не удалось вставить изображение.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Для создания коротких постоянных ссылок изображения оптимизируются и загружаются в общедоступное хранилище изображений Markdown Viewer. Любой, у кого есть URL-адрес изображения, может просмотреть его. Продолжать?", + "uploaded and inserted with short links.": "загружено и вставлено с короткими ссылками.", + "Uploading": "Загрузка" } diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index 525ee9cb..32210455 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -11,7 +11,6 @@ ", file location": ", dosya konumu", ", locked": ", kilitli", ", or": "veya", - ".md or .markdown files supported": ".md veya .markdown dosyaları desteklenir", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" stil = \"genişlik:% 100; yükseklik:%100; ekran:esnek; hizalama öğeleri:ortalama; yasla-içerik:merkez; taşma:gizli;\">", "{{0}} characters": "{{0}} karakter", "{{0}} details go here.": "{{0}} ayrıntı buraya gelecek.", @@ -253,7 +252,6 @@ "Download started": "İndirme başladı", "Download SVG": "SVG'yi indirin", "Drag files to move": "Taşımak için dosyaları sürükleyin", - "Drop your Markdown files anywhere": "Markdown dosyalarınızı istediğiniz yere bırakın", "Duet Accord": "Düet Anlaşması", "Duplicate": "Çoğalt", "Duplicate {{0}}": "Kopya {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Yanlış erişim anahtarı veya okunamayan Gizli Çalışma Alanı verileri.", "Live room disconnected": "Canlı odanın bağlantısı kesildi", "Maximum document limit reached": "Maksimum belge sınırına ulaşıldı", - "Mermaid diagram": "Denizkızı diyagramı", - "Mermaid diagram actions": "Denizkızı diyagramı eylemleri", "No documents match your search.": "Aramanızla eşleşen belge yok.", "Open or create a document to use editing tools.": "Düzenleme araçlarını kullanmak için bir belge açın veya oluşturun.", "Review item deleted.": "İnceleme öğesi silindi.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Bu paylaşım bağlantısının süresi dolmuş veya mevcut değil.", "This shared document is read-only for you.": "Bu paylaşılan belge sizin için salt okunurdur.", "Your file is ready.": "Dosyanız hazır.", - "Your files are ready.": "Dosyalarınız hazır." + "Your files are ready.": "Dosyalarınız hazır.", + "Collapse": "Daralt", + "converted to short links.": "kısa bağlantılara dönüştürüldü.", + "Converting embedded images to short links.": "Gömülü görüntüleri kısa bağlantılara dönüştürme.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Cihaz görüntüleri, kısa bir genel bağlantı oluşturmak için optimize edildi ve yüklendi. Resim URL'sine sahip olan herkes onu görüntüleyebilir.", + "Drop Markdown files or images to add them.": "Eklemek için Markdown dosya veya görseli bırakın.", + "Drop to import or insert": "İçe aktarmak veya eklemek için bırakın", + "embedded image": "gömülü görüntü", + "Embedded images could not be converted. Please try again.": "Gömülü görüntüler dönüştürülemedi. Lütfen tekrar deneyin.", + "expanded.": "genişletildi.\nKısa bağlantılar için", + "for short links.": ".", + "Image conversion was cancelled because the document changed.": "Belge değiştiği için görüntü dönüştürme iptal edildi.", + "Image insertion was cancelled because the document changed.": "Belge değiştiği için resim ekleme iptal edildi.", + "Image upload cancelled.": "Resim yükleme iptal edildi.", + "Images larger than 25MB cannot be inserted.": "25 MB'tan büyük resimler eklenemez.", + "Markdown files and images are supported": "Markdown dosyaları ve görselleri desteklenir", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Görüntüleri eklemeden önce düzenlenebilir bir Markdown dosyasını açın.", + "The image could not be inserted.": "Resim eklenemedi.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Kısa kalıcı bağlantılar oluşturmak için resimler optimize edilir ve Markdown Viewer genel resim deposuna yüklenir. Resim URL'sine sahip olan herkes bunu görüntüleyebilir. Devam etmek?", + "uploaded and inserted with short links.": "kısa bağlantılarla yüklendi ve eklendi.", + "Uploading": "Yükleniyor" } diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index a31b919f..ddde06b1 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -11,7 +11,6 @@ ", file location": ",文件位置", ", locked": ",已鎖定", ", or": ",或", - ".md or .markdown files supported": "支援 .md 或 .markdown 文件", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "「樣式=」寬度:100%;高度:100%;顯示:柔性;對齊項目:居中;調整內容:居中;溢出:隱藏;\">", "{{0}} characters": "{{0}} 個字符", "{{0}} details go here.": "{{0}} 詳細資料請參閱此處。", @@ -253,7 +252,6 @@ "Download started": "下載開始", "Download SVG": "下載 SVG", "Drag files to move": "拖曳檔案進行移動", - "Drop your Markdown files anywhere": "將 Markdown 檔案拖曳到任何地方", "Duet Accord": "二重奏協奏曲", "Duplicate": "重複", "Duplicate {{0}}": "重複 {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "存取金鑰不正確或秘密工作區資料不可讀。", "Live room disconnected": "直播間斷線", "Maximum document limit reached": "達到最大文件限制", - "Mermaid diagram": "人魚圖", - "Mermaid diagram actions": "人魚圖動作", "No documents match your search.": "沒有與您的搜尋相符的文件。", "Open or create a document to use editing tools.": "開啟或建立文件以使用編輯工具。", "Review item deleted.": "評論項目已刪除。", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "此分享連結已過期或不存在。", "This shared document is read-only for you.": "此共用文件對您來說是唯讀的。", "Your file is ready.": "您的文件已準備好。", - "Your files are ready.": "您的文件已準備好。" + "Your files are ready.": "您的文件已準備好。", + "Collapse": "折疊", + "converted to short links.": "轉換為短連結。", + "Converting embedded images to short links.": "將嵌入影像轉換為短連結。", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "設備圖像經過最佳化並上傳以建立簡短的公共連結。知道圖像 URL 的任何人都可以查看它。", + "Drop Markdown files or images to add them.": "刪除 Markdown 檔案或影像以新增它們。", + "Drop to import or insert": "拖曳以匯入或插入", + "embedded image": "嵌入影像", + "Embedded images could not be converted. Please try again.": "無法轉換嵌入影像。請再試一次。", + "expanded.": "擴展。", + "for short links.": "用於短連結。", + "Image conversion was cancelled because the document changed.": "由於文件更改,影像轉換被取消。", + "Image insertion was cancelled because the document changed.": "由於文件更改,影像插入被取消。", + "Image upload cancelled.": "圖片上傳已取消。", + "Images larger than 25MB cannot be inserted.": "無法插入大於 25MB 的影像。", + "Markdown files and images are supported": "支援 Markdown 文件和影像", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "在插入影像之前開啟可編輯的 Markdown 檔案。", + "The image could not be inserted.": "無法插入影像。", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "為了創建短的持久鏈接,圖像經過優化並上傳到 Markdown Viewer 公共圖像存儲。任何擁有圖像 URL 的人都可以查看它。繼續?", + "uploaded and inserted with short links.": "已上傳並插入短連結。", + "Uploading": "上傳中" } diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index b21ddf97..751ce49e 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -11,7 +11,6 @@ ", file location": ", розташування файлу", ", locked": ", заблоковано", ", or": "або", - ".md or .markdown files supported": "Підтримуються файли .md або .markdown", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; висота: 100%; дисплей: гнучкий; align-items:center; justify-content:center; overflow:hidden;\">", "{{0}} characters": "{{0}} символів", "{{0}} details go here.": "{{0}} деталі дивіться тут.\nВибрано файл", @@ -253,7 +252,6 @@ "Download started": "Завантаження розпочато", "Download SVG": "Завантажити SVG", "Drag files to move": "Перетягніть файли для переміщення", - "Drop your Markdown files anywhere": "Перетягніть свої файли Markdown будь-куди", "Duet Accord": "Дует Акорд", "Duplicate": "Дублікат", "Duplicate {{0}}": "Дублікат {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "Неправильний ключ доступу або нечитабельні дані Secret Workspace.", "Live room disconnected": "Живу кімнату відключено", "Maximum document limit reached": "Досягнуто максимальної кількості документів", - "Mermaid diagram": "Діаграма русалки", - "Mermaid diagram actions": "Дії діаграми русалок", "No documents match your search.": "Немає документів, що відповідають вашому пошуку.", "Open or create a document to use editing tools.": "Відкрийте або створіть документ для використання інструментів редагування.", "Review item deleted.": "Елемент огляду видалено.", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "Це посилання для спільного використання застаріло або не існує.", "This shared document is read-only for you.": "Цей спільний документ доступний лише для читання.", "Your file is ready.": "Ваш файл готовий.", - "Your files are ready.": "Ваші файли готові." + "Your files are ready.": "Ваші файли готові.", + "Collapse": "Згорнути", + "converted to short links.": "перетворено на короткі посилання.", + "Converting embedded images to short links.": "Перетворення вбудованих зображень на короткі посилання.", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Зображення пристроїв оптимізовано та завантажено для створення короткого загальнодоступного посилання. Будь-хто, хто має URL-адресу зображення, може його переглянути.", + "Drop Markdown files or images to add them.": "Перетягніть Markdown файли або зображення, щоб додати їх.", + "Drop to import or insert": "Перетягніть, щоб імпортувати або вставити", + "embedded image": "вбудоване зображення", + "Embedded images could not be converted. Please try again.": "Вбудовані зображення не вдалося конвертувати. Спробуйте ще раз.", + "expanded.": "розширено.", + "for short links.": "для коротких посилань.", + "Image conversion was cancelled because the document changed.": "Перетворення зображення скасовано, оскільки документ змінено.", + "Image insertion was cancelled because the document changed.": "Вставлення зображення скасовано, оскільки документ змінено.", + "Image upload cancelled.": "Завантаження зображення скасовано.", + "Images larger than 25MB cannot be inserted.": "Не можна вставляти зображення розміром понад 25 МБ.", + "Markdown files and images are supported": "Підтримуються файли та зображення Markdown", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "Відкрийте редагований файл Markdown перед вставленням зображень.", + "The image could not be inserted.": "Не вдалося вставити зображення.", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Для створення коротких постійних посилань зображення оптимізовано та завантажено до загальнодоступного сховища зображень Markdown Viewer. Будь-хто, хто має URL-адресу зображення, може його переглянути. Продовжити?", + "uploaded and inserted with short links.": "завантажено та вставлено з короткими посиланнями.", + "Uploading": "Завантаження" } diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 429a01ee..fd8d2ca8 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -11,7 +11,6 @@ ", file location": ",文件位置", ", locked": ",已锁定", ", or": ",或", - ".md or .markdown files supported": "支持 .md 或 .markdown 文件", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "“样式=”宽度:100%;高度:100%;显示:柔性;对齐项目:居中;调整内容:居中;溢出:隐藏;\">", "{{0}} characters": "{{0}} 个字符", "{{0}} details go here.": "{{0}} 详细信息请参见此处。", @@ -253,7 +252,6 @@ "Download started": "下载开始", "Download SVG": "下载 SVG", "Drag files to move": "拖动文件进行移动", - "Drop your Markdown files anywhere": "将 Markdown 文件拖放到任何地方", "Duet Accord": "二重奏协奏曲", "Duplicate": "重复", "Duplicate {{0}}": "重复 {{0}}", @@ -1009,8 +1007,6 @@ "Incorrect access key or unreadable Secret Workspace data.": "访问密钥不正确或秘密工作区数据不可读。", "Live room disconnected": "直播间断线", "Maximum document limit reached": "达到最大文档限制", - "Mermaid diagram": "人鱼图", - "Mermaid diagram actions": "人鱼图动作", "No documents match your search.": "没有与您的搜索匹配的文档。", "Open or create a document to use editing tools.": "打开或创建文档以使用编辑工具。", "Review item deleted.": "评论项目已删除。", @@ -1027,5 +1023,26 @@ "This share link has expired or does not exist.": "此分享链接已过期或不存在。", "This shared document is read-only for you.": "此共享文档对您来说是只读的。", "Your file is ready.": "您的文件已准备好。", - "Your files are ready.": "您的文件已准备好。" + "Your files are ready.": "您的文件已准备好。", + "Collapse": "折叠", + "converted to short links.": "转换为短链接。", + "Converting embedded images to short links.": "将嵌入图像转换为短链接。", + "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "设备图像经过优化并上传以创建简短的公共链接。知道图像 URL 的任何人都可以查看它。", + "Drop Markdown files or images to add them.": "删除 Markdown 文件或图像以添加它们。", + "Drop to import or insert": "拖放以导入或插入", + "embedded image": "嵌入图像", + "Embedded images could not be converted. Please try again.": "无法转换嵌入图像。请再试一次。", + "expanded.": "扩展。", + "for short links.": "用于短链接。", + "Image conversion was cancelled because the document changed.": "由于文档更改,图像转换被取消。", + "Image insertion was cancelled because the document changed.": "由于文档更改,图像插入被取消。", + "Image upload cancelled.": "图片上传已取消。", + "Images larger than 25MB cannot be inserted.": "无法插入大于 25MB 的图像。", + "Markdown files and images are supported": "支持 Markdown 文件和图像", + "MD": "MD", + "Open an editable Markdown file before inserting images.": "在插入图像之前打开可编辑的 Markdown 文件。", + "The image could not be inserted.": "无法插入图像。", + "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "为了创建短的持久链接,图像经过优化并上传到 Markdown Viewer 公共图像存储。任何拥有图像 URL 的人都可以查看它。继续?", + "uploaded and inserted with short links.": "已上传并插入短链接。", + "Uploading": "上传中" } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index f035c3c0..b0219fc5 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1020,6 +1020,7 @@

Markdown Viewer

diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 3272f1f6..436078b7 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -574,8 +574,9 @@ document.addEventListener("DOMContentLoaded", async function () { const EMBEDDED_IMAGE_TARGET_BYTES = 180 * 1024; const EMBEDDED_IMAGE_MAX_BYTES = 280 * 1024; const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; - const MAX_NORMAL_WORKSPACE_STORAGE_CHARS = 4 * 1024 * 1024; - const MAX_SECRET_WORKSPACE_STORAGE_CHARS = 3 * 1024 * 1024; + const MANAGED_IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; + const managedImageMigrationOfferedTabIds = new Set(); + const managedImageMigrationInProgressTabIds = new Set(); const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -7191,6 +7192,7 @@ document.addEventListener("DOMContentLoaded", async function () { renderLiveCursors(); }); renderTabBar(tabs, activeTabId); + scheduleEmbeddedImageMigrationOffer(tabId); } function newTab(content, title, location) { @@ -7595,6 +7597,7 @@ document.addEventListener("DOMContentLoaded", async function () { renderTabBar(tabs, activeTabId); initDocumentSidebar(); setupTabOverflow(); + if (activeTab) scheduleEmbeddedImageMigrationOffer(activeTab.id); const staticNewBtn = document.getElementById('tab-new-btn'); if (staticNewBtn) { @@ -10599,23 +10602,124 @@ ${selector} .arrowheadPath { } } - function canPersistImageInsertion(targetTabId, nextContent) { - if (isPrivateStorageMode() || (liveCollaboration && liveCollaboration.tabId === targetTabId)) return true; - const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); - if (!targetTab || isTemporaryDocument(targetTab)) return true; + function requestManagedImageUploadConsent() { + if (loadGlobalState().managedImageUploadAcknowledged === true) return true; + const accepted = confirm( + 'To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?' + ); + if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); + return accepted; + } - if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { - const payload = getSecretWorkspacePayload(); - payload.documents = payload.documents.map(function(tab) { - return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + async function uploadManagedImageDataUrl(dataUrl) { + let response; + try { + response = await fetch(getShareApiBaseUrl() + '/api/image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl: dataUrl }) }); - return JSON.stringify(payload).length <= MAX_SECRET_WORKSPACE_STORAGE_CHARS; + } catch (_) { + throw new Error('The image could not be uploaded. Check your connection and try again, or use External Image (URL).'); } - const storageTabs = getTabsForStorage(tabs).map(function(tab) { - return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; - }); - return JSON.stringify(storageTabs).length <= MAX_NORMAL_WORKSPACE_STORAGE_CHARS; + let payload = null; + try { + payload = await response.json(); + } catch (_) {} + if (!response.ok) { + throw new Error((payload && payload.error) || 'The image host rejected this file. Try a smaller image or use External Image (URL).'); + } + if (!payload || !MANAGED_IMAGE_ID_PATTERN.test(payload.id || '')) { + throw new Error('The image host returned an invalid short link. Please try again.'); + } + + const expectedBase = new URL(getShareApiBaseUrl()); + const fallbackUrl = new URL('/api/image/' + payload.id, expectedBase); + let managedUrl; + try { + managedUrl = new URL(payload.url || fallbackUrl.toString()); + } catch (_) { + managedUrl = fallbackUrl; + } + if (managedUrl.origin !== expectedBase.origin || managedUrl.pathname !== '/api/image/' + payload.id) { + throw new Error('The image host returned an invalid short link. Please try again.'); + } + return managedUrl.toString(); + } + + function getEmbeddedMarkdownImageRegex() { + return /(!\[[^\]\r\n]*\]\()(data:image\/(?:avif|bmp|gif|jpe?g|png|webp);base64,[A-Za-z0-9+/]+={0,2})((?:\s+"[^"\r\n]*")?\))/gi; + } + + function hasEmbeddedMarkdownImages(markdown) { + return getEmbeddedMarkdownImageRegex().test(String(markdown || '')); + } + + async function migrateEmbeddedImagesToManagedUrls(tabId) { + if (managedImageMigrationInProgressTabIds.has(tabId) || activeTabId !== tabId || !canMutateEditor()) return false; + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab) || !hasEmbeddedMarkdownImages(markdownEditor.value)) return false; + managedImageMigrationInProgressTabIds.add(tabId); + const originalValue = markdownEditor.value; + const selectionStart = markdownEditor.selectionStart; + const selectionEnd = markdownEditor.selectionEnd; + let didReplaceEditorContent = false; + + try { + if (!requestManagedImageUploadConsent()) return false; + const dataUrls = []; + let match; + const scanPattern = getEmbeddedMarkdownImageRegex(); + while ((match = scanPattern.exec(originalValue)) !== null) { + if (!dataUrls.includes(match[2])) dataUrls.push(match[2]); + } + if (!dataUrls.length) return false; + announceToScreenReader('Converting embedded images to short links.'); + const shortLinks = new Map(); + for (const dataUrl of dataUrls) { + shortLinks.set(dataUrl, await uploadManagedImageDataUrl(dataUrl)); + } + if (activeTabId !== tabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + announceToScreenReader('Image conversion was cancelled because the document changed.'); + return false; + } + + const nextValue = originalValue.replace(getEmbeddedMarkdownImageRegex(), function(full, prefix, dataUrl, suffix) { + return prefix + shortLinks.get(dataUrl) + suffix; + }); + replaceEditorRange( + 0, + originalValue.length, + nextValue, + Math.min(selectionStart, nextValue.length), + Math.min(selectionEnd, nextValue.length) + ); + didReplaceEditorContent = true; + const persisted = await persistImageInsertion(tabId); + if (!persisted) throw new Error('The browser could not save the converted image links.'); + announceToScreenReader(dataUrls.length + ' embedded image' + (dataUrls.length === 1 ? '' : 's') + ' converted to short links.'); + return true; + } catch (error) { + if (didReplaceEditorContent) { + restoreEditorAfterFailedImageInsertion(tabId, originalValue, selectionStart, selectionEnd); + } + console.warn('Embedded image conversion failed:', error); + alert(error && error.message ? error.message : 'Embedded images could not be converted. Please try again.'); + return false; + } finally { + managedImageMigrationInProgressTabIds.delete(tabId); + } + } + + function scheduleEmbeddedImageMigrationOffer(tabId) { + if (!tabId || managedImageMigrationOfferedTabIds.has(tabId)) return; + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab) || !hasEmbeddedMarkdownImages(tab.content)) return; + managedImageMigrationOfferedTabIds.add(tabId); + setTimeout(function() { + if (activeTabId === tabId) migrateEmbeddedImagesToManagedUrls(tabId); + }, 700); } async function persistImageInsertion(targetTabId) { @@ -10658,13 +10762,20 @@ ${selector} .arrowheadPath { alert('Images larger than 25MB cannot be inserted.'); } if (!acceptedFiles.length) return 0; + if (!requestManagedImageUploadConsent()) { + announceToScreenReader('Image upload cancelled.'); + return 0; + } const targetTabId = activeTabId; const originalValue = markdownEditor.value; const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; - const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; + const source = settings.source === 'clipboard' + ? 'clipboard' + : (settings.source === 'upload' ? 'upload' : 'drop'); let didInsert = false; + if (typeof settings.onStage === 'function') settings.onStage('preparing'); announceToScreenReader('Preparing ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + '.'); try { @@ -10672,32 +10783,33 @@ ${selector} .arrowheadPath { for (const file of acceptedFiles) { preparedImages.push(await createEmbeddedImageDataUrl(file)); } + if (typeof settings.onStage === 'function') settings.onStage('uploading'); + announceToScreenReader('Uploading ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' for short links.'); + const managedUrls = []; + for (const dataUrl of preparedImages) { + managedUrls.push(await uploadManagedImageDataUrl(dataUrl)); + } if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { announceToScreenReader('Image insertion was cancelled because the document changed.'); return 0; } - const markdown = preparedImages.map(function(dataUrl, index) { + const markdown = managedUrls.map(function(managedUrl, index) { const file = acceptedFiles[index]; const altText = typeof settings.altText === 'string' && acceptedFiles.length === 1 ? (settings.altText.trim() || 'alt text') : getLocalImageAltText(file, source, index, acceptedFiles.length); if (typeof settings.markdownFactory === 'function') { - return settings.markdownFactory(dataUrl, altText, file, index, acceptedFiles.length); + return settings.markdownFactory(managedUrl, altText, file, index, acceptedFiles.length); } - return '![' + altText + '](' + dataUrl + ')'; + return '![' + altText + '](' + managedUrl + ')'; }).join('\n\n'); - const nextValue = originalValue.slice(0, start) + markdown + originalValue.slice(end); - if (!canPersistImageInsertion(targetTabId, nextValue)) { - alert('These images would exceed safe local browser storage. Insert fewer images, use smaller files, or use external image URLs.'); - return 0; - } replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); didInsert = true; const persisted = await persistImageInsertion(targetTabId); - if (!persisted) throw new Error('The browser could not save the embedded image.'); - announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted and saved with the document.'); + if (!persisted) throw new Error('The browser could not save the short image link.'); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' uploaded and inserted with short links.'); return acceptedFiles.length; } catch (error) { if (didInsert) { @@ -13381,11 +13493,13 @@ ${selector} .arrowheadPath { replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } - function setProcessing(processing) { + function setProcessing(processing, stage) { isProcessing = processing; modal.setAttribute('aria-busy', processing ? 'true' : 'false'); confirmBtn.disabled = processing; - confirmBtn.textContent = processing ? translateUiString('Preparing') + '…' : confirmButtonLabel; + confirmBtn.textContent = processing + ? translateUiString(stage === 'uploading' ? 'Uploading' : 'Preparing') + '...' + : confirmButtonLabel; cancelBtn.disabled = processing; uploadOption.disabled = processing; urlOption.disabled = processing; @@ -13402,6 +13516,9 @@ ${selector} .arrowheadPath { selectionStart: start, selectionEnd: end, altText: altInput.value, + onStage: function(stage) { + setProcessing(true, stage); + }, markdownFactory: function(dataUrl) { return buildImageMarkdown(dataUrl); } @@ -20631,7 +20748,7 @@ ${selector} .arrowheadPath { if (imageFiles.length) { const insertedCount = await insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); handledCount += insertedCount; - if (!insertedCount && !markdownFiles.length) { + if (!insertedCount && !markdownFiles.length && (!hasActiveOpenDocument() || !canMutateEditor())) { alert('Open an editable Markdown file before inserting images.'); } } diff --git a/functions/api/image/[[id]].js b/functions/api/image/[[id]].js new file mode 100644 index 00000000..4a74c86b --- /dev/null +++ b/functions/api/image/[[id]].js @@ -0,0 +1,201 @@ +const MAX_IMAGE_BYTES = 300 * 1024; +const MAX_DATA_URL_CHARS = 420000; +const IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; +const IMAGE_KEY_PREFIX = "managed-image-v1:"; +const ALLOWED_ORIGINS = new Set([ + "https://markdownviewer.pages.dev", + "null" +]); +const ALLOWED_IMAGE_TYPES = new Set([ + "image/avif", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/webp" +]); + +function isAllowedOrigin(origin) { + if (!origin) return true; + if (ALLOWED_ORIGINS.has(origin)) return true; + if (/^https:\/\/[a-z0-9-]+\.markdownviewer\.pages\.dev$/i.test(origin)) return true; + return /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin); +} + +function applySecurityHeaders(headers) { + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + headers.set("Permissions-Policy", "accelerometer=(), autoplay=(), browsing-topics=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), usb=(), xr-spatial-tracking=()"); +} + +function jsonResponse(body, init, request) { + const headers = new Headers(init && init.headers); + headers.set("Content-Type", "application/json; charset=utf-8"); + headers.set("Cache-Control", "no-store"); + applySecurityHeaders(headers); + const origin = request && request.headers.get("Origin"); + if (origin && isAllowedOrigin(origin)) { + headers.set("Access-Control-Allow-Origin", origin); + headers.set("Vary", "Origin"); + } + return new Response(JSON.stringify(body), { + status: init && init.status ? init.status : 200, + headers + }); +} + +function getImageId(params) { + const raw = params && params.id; + if (Array.isArray(raw)) return raw.join("/"); + return typeof raw === "string" ? raw : ""; +} + +function decodeBase64(value) { + if (!value || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null; + try { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; + } catch (_) { + return null; + } +} + +function bytesStartWith(bytes, values) { + return values.every((value, index) => bytes[index] === value); +} + +function asciiSlice(bytes, start, end) { + return String.fromCharCode.apply(null, bytes.subarray(start, end)); +} + +function hasValidImageSignature(mimeType, bytes) { + if (!bytes || !bytes.length) return false; + if (mimeType === "image/png") return bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (mimeType === "image/jpeg") return bytesStartWith(bytes, [0xff, 0xd8, 0xff]); + if (mimeType === "image/gif") return asciiSlice(bytes, 0, 6) === "GIF87a" || asciiSlice(bytes, 0, 6) === "GIF89a"; + if (mimeType === "image/webp") return asciiSlice(bytes, 0, 4) === "RIFF" && asciiSlice(bytes, 8, 12) === "WEBP"; + if (mimeType === "image/bmp") return asciiSlice(bytes, 0, 2) === "BM"; + if (mimeType === "image/avif") { + const brand = asciiSlice(bytes, 8, 12); + return asciiSlice(bytes, 4, 8) === "ftyp" && (brand === "avif" || brand === "avis"); + } + return false; +} + +function parseImageDataUrl(value) { + if (typeof value !== "string" || value.length > MAX_DATA_URL_CHARS) return null; + const match = value.match(/^data:(image\/(?:avif|bmp|gif|jpeg|png|webp));base64,([A-Za-z0-9+/]+={0,2})$/i); + if (!match) return null; + const mimeType = match[1].toLowerCase(); + if (!ALLOWED_IMAGE_TYPES.has(mimeType)) return null; + const bytes = decodeBase64(match[2]); + if (!bytes || bytes.byteLength > MAX_IMAGE_BYTES || !hasValidImageSignature(mimeType, bytes)) return null; + return { mimeType, base64: match[2], bytes }; +} + +async function createImageId(mimeType, bytes) { + const typeBytes = new TextEncoder().encode(mimeType + "\0"); + const input = new Uint8Array(typeBytes.length + bytes.length); + input.set(typeBytes, 0); + input.set(bytes, typeBytes.length); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input)); + let binary = ""; + digest.forEach(byte => { binary += String.fromCharCode(byte); }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").slice(0, 24); +} + +function getPublicImageUrl(request, id) { + const url = new URL(request.url); + url.pathname = "/api/image/" + id; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export async function onRequest({ request, env, params }) { + const id = getImageId(params); + + if (request.method === "OPTIONS") { + const headers = new Headers({ + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Cache-Control": "no-store" + }); + const origin = request.headers.get("Origin") || ""; + if (isAllowedOrigin(origin) && origin) headers.set("Access-Control-Allow-Origin", origin); + applySecurityHeaders(headers); + return new Response(null, { status: 204, headers }); + } + + if (!env || !env.SHARE_KV) { + return jsonResponse({ error: "image storage is not configured" }, { status: 503 }, request); + } + + if (request.method === "POST" && !id) { + const origin = request.headers.get("Origin") || ""; + if (!isAllowedOrigin(origin)) return jsonResponse({ error: "origin not allowed" }, { status: 403 }, request); + const contentLength = Number(request.headers.get("Content-Length")); + if (Number.isFinite(contentLength) && contentLength > MAX_DATA_URL_CHARS + 1024) { + return jsonResponse({ error: "image upload is too large" }, { status: 413 }, request); + } + let body; + try { + body = await request.json(); + } catch (_) { + return jsonResponse({ error: "invalid json" }, { status: 400 }, request); + } + const image = parseImageDataUrl(body && body.dataUrl); + if (!image) return jsonResponse({ error: "invalid or oversized raster image" }, { status: 400 }, request); + + const imageId = await createImageId(image.mimeType, image.bytes); + const storageKey = IMAGE_KEY_PREFIX + imageId; + const existing = await env.SHARE_KV.get(storageKey); + if (!existing) { + await env.SHARE_KV.put(storageKey, JSON.stringify({ + version: 1, + mimeType: image.mimeType, + base64: image.base64, + size: image.bytes.byteLength, + createdAt: Date.now() + })); + } + + return jsonResponse({ + id: imageId, + url: getPublicImageUrl(request, imageId), + mimeType: image.mimeType, + size: image.bytes.byteLength + }, { status: existing ? 200 : 201 }, request); + } + + if ((request.method === "GET" || request.method === "HEAD") && IMAGE_ID_PATTERN.test(id)) { + const raw = await env.SHARE_KV.get(IMAGE_KEY_PREFIX + id); + if (!raw) return jsonResponse({ error: "image not found" }, { status: 404 }, request); + let record; + try { + record = JSON.parse(raw); + } catch (_) { + return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); + } + if (!record || !ALLOWED_IMAGE_TYPES.has(record.mimeType)) { + return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); + } + const bytes = decodeBase64(record.base64); + if (!bytes || bytes.byteLength > MAX_IMAGE_BYTES || !hasValidImageSignature(record.mimeType, bytes)) { + return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); + } + const headers = new Headers({ + "Content-Type": record.mimeType, + "Content-Length": String(bytes.byteLength), + "Cache-Control": "public, max-age=31536000, immutable", + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin" + }); + applySecurityHeaders(headers); + return new Response(request.method === "HEAD" ? null : bytes, { status: 200, headers }); + } + + return jsonResponse({ error: "not found" }, { status: 404 }, request); +} diff --git a/index.html b/index.html index 7d7b9627..a52e2893 100644 --- a/index.html +++ b/index.html @@ -1113,6 +1113,7 @@

Markdown Viewer

diff --git a/script.js b/script.js index 3272f1f6..436078b7 100644 --- a/script.js +++ b/script.js @@ -574,8 +574,9 @@ document.addEventListener("DOMContentLoaded", async function () { const EMBEDDED_IMAGE_TARGET_BYTES = 180 * 1024; const EMBEDDED_IMAGE_MAX_BYTES = 280 * 1024; const EMBEDDED_IMAGE_MAX_DIMENSION = 1600; - const MAX_NORMAL_WORKSPACE_STORAGE_CHARS = 4 * 1024 * 1024; - const MAX_SECRET_WORKSPACE_STORAGE_CHARS = 3 * 1024 * 1024; + const MANAGED_IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; + const managedImageMigrationOfferedTabIds = new Set(); + const managedImageMigrationInProgressTabIds = new Set(); const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; @@ -7191,6 +7192,7 @@ document.addEventListener("DOMContentLoaded", async function () { renderLiveCursors(); }); renderTabBar(tabs, activeTabId); + scheduleEmbeddedImageMigrationOffer(tabId); } function newTab(content, title, location) { @@ -7595,6 +7597,7 @@ document.addEventListener("DOMContentLoaded", async function () { renderTabBar(tabs, activeTabId); initDocumentSidebar(); setupTabOverflow(); + if (activeTab) scheduleEmbeddedImageMigrationOffer(activeTab.id); const staticNewBtn = document.getElementById('tab-new-btn'); if (staticNewBtn) { @@ -10599,23 +10602,124 @@ ${selector} .arrowheadPath { } } - function canPersistImageInsertion(targetTabId, nextContent) { - if (isPrivateStorageMode() || (liveCollaboration && liveCollaboration.tabId === targetTabId)) return true; - const targetTab = tabs.find(function(tab) { return tab.id === targetTabId; }); - if (!targetTab || isTemporaryDocument(targetTab)) return true; + function requestManagedImageUploadConsent() { + if (loadGlobalState().managedImageUploadAcknowledged === true) return true; + const accepted = confirm( + 'To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?' + ); + if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); + return accepted; + } - if (targetTab.workspaceId === SECRET_WORKSPACE_ID) { - const payload = getSecretWorkspacePayload(); - payload.documents = payload.documents.map(function(tab) { - return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; + async function uploadManagedImageDataUrl(dataUrl) { + let response; + try { + response = await fetch(getShareApiBaseUrl() + '/api/image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl: dataUrl }) }); - return JSON.stringify(payload).length <= MAX_SECRET_WORKSPACE_STORAGE_CHARS; + } catch (_) { + throw new Error('The image could not be uploaded. Check your connection and try again, or use External Image (URL).'); } - const storageTabs = getTabsForStorage(tabs).map(function(tab) { - return tab.id === targetTabId ? Object.assign({}, tab, { content: nextContent }) : tab; - }); - return JSON.stringify(storageTabs).length <= MAX_NORMAL_WORKSPACE_STORAGE_CHARS; + let payload = null; + try { + payload = await response.json(); + } catch (_) {} + if (!response.ok) { + throw new Error((payload && payload.error) || 'The image host rejected this file. Try a smaller image or use External Image (URL).'); + } + if (!payload || !MANAGED_IMAGE_ID_PATTERN.test(payload.id || '')) { + throw new Error('The image host returned an invalid short link. Please try again.'); + } + + const expectedBase = new URL(getShareApiBaseUrl()); + const fallbackUrl = new URL('/api/image/' + payload.id, expectedBase); + let managedUrl; + try { + managedUrl = new URL(payload.url || fallbackUrl.toString()); + } catch (_) { + managedUrl = fallbackUrl; + } + if (managedUrl.origin !== expectedBase.origin || managedUrl.pathname !== '/api/image/' + payload.id) { + throw new Error('The image host returned an invalid short link. Please try again.'); + } + return managedUrl.toString(); + } + + function getEmbeddedMarkdownImageRegex() { + return /(!\[[^\]\r\n]*\]\()(data:image\/(?:avif|bmp|gif|jpe?g|png|webp);base64,[A-Za-z0-9+/]+={0,2})((?:\s+"[^"\r\n]*")?\))/gi; + } + + function hasEmbeddedMarkdownImages(markdown) { + return getEmbeddedMarkdownImageRegex().test(String(markdown || '')); + } + + async function migrateEmbeddedImagesToManagedUrls(tabId) { + if (managedImageMigrationInProgressTabIds.has(tabId) || activeTabId !== tabId || !canMutateEditor()) return false; + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab) || !hasEmbeddedMarkdownImages(markdownEditor.value)) return false; + managedImageMigrationInProgressTabIds.add(tabId); + const originalValue = markdownEditor.value; + const selectionStart = markdownEditor.selectionStart; + const selectionEnd = markdownEditor.selectionEnd; + let didReplaceEditorContent = false; + + try { + if (!requestManagedImageUploadConsent()) return false; + const dataUrls = []; + let match; + const scanPattern = getEmbeddedMarkdownImageRegex(); + while ((match = scanPattern.exec(originalValue)) !== null) { + if (!dataUrls.includes(match[2])) dataUrls.push(match[2]); + } + if (!dataUrls.length) return false; + announceToScreenReader('Converting embedded images to short links.'); + const shortLinks = new Map(); + for (const dataUrl of dataUrls) { + shortLinks.set(dataUrl, await uploadManagedImageDataUrl(dataUrl)); + } + if (activeTabId !== tabId || markdownEditor.value !== originalValue || !canMutateEditor()) { + announceToScreenReader('Image conversion was cancelled because the document changed.'); + return false; + } + + const nextValue = originalValue.replace(getEmbeddedMarkdownImageRegex(), function(full, prefix, dataUrl, suffix) { + return prefix + shortLinks.get(dataUrl) + suffix; + }); + replaceEditorRange( + 0, + originalValue.length, + nextValue, + Math.min(selectionStart, nextValue.length), + Math.min(selectionEnd, nextValue.length) + ); + didReplaceEditorContent = true; + const persisted = await persistImageInsertion(tabId); + if (!persisted) throw new Error('The browser could not save the converted image links.'); + announceToScreenReader(dataUrls.length + ' embedded image' + (dataUrls.length === 1 ? '' : 's') + ' converted to short links.'); + return true; + } catch (error) { + if (didReplaceEditorContent) { + restoreEditorAfterFailedImageInsertion(tabId, originalValue, selectionStart, selectionEnd); + } + console.warn('Embedded image conversion failed:', error); + alert(error && error.message ? error.message : 'Embedded images could not be converted. Please try again.'); + return false; + } finally { + managedImageMigrationInProgressTabIds.delete(tabId); + } + } + + function scheduleEmbeddedImageMigrationOffer(tabId) { + if (!tabId || managedImageMigrationOfferedTabIds.has(tabId)) return; + const tab = tabs.find(function(item) { return item.id === tabId; }); + if (!tab || isTemporaryDocument(tab) || !hasEmbeddedMarkdownImages(tab.content)) return; + managedImageMigrationOfferedTabIds.add(tabId); + setTimeout(function() { + if (activeTabId === tabId) migrateEmbeddedImagesToManagedUrls(tabId); + }, 700); } async function persistImageInsertion(targetTabId) { @@ -10658,13 +10762,20 @@ ${selector} .arrowheadPath { alert('Images larger than 25MB cannot be inserted.'); } if (!acceptedFiles.length) return 0; + if (!requestManagedImageUploadConsent()) { + announceToScreenReader('Image upload cancelled.'); + return 0; + } const targetTabId = activeTabId; const originalValue = markdownEditor.value; const start = Number.isFinite(settings.selectionStart) ? settings.selectionStart : markdownEditor.selectionStart; const end = Number.isFinite(settings.selectionEnd) ? settings.selectionEnd : markdownEditor.selectionEnd; - const source = settings.source === 'clipboard' ? 'clipboard' : 'drop'; + const source = settings.source === 'clipboard' + ? 'clipboard' + : (settings.source === 'upload' ? 'upload' : 'drop'); let didInsert = false; + if (typeof settings.onStage === 'function') settings.onStage('preparing'); announceToScreenReader('Preparing ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + '.'); try { @@ -10672,32 +10783,33 @@ ${selector} .arrowheadPath { for (const file of acceptedFiles) { preparedImages.push(await createEmbeddedImageDataUrl(file)); } + if (typeof settings.onStage === 'function') settings.onStage('uploading'); + announceToScreenReader('Uploading ' + acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' for short links.'); + const managedUrls = []; + for (const dataUrl of preparedImages) { + managedUrls.push(await uploadManagedImageDataUrl(dataUrl)); + } if (activeTabId !== targetTabId || markdownEditor.value !== originalValue || !canMutateEditor()) { announceToScreenReader('Image insertion was cancelled because the document changed.'); return 0; } - const markdown = preparedImages.map(function(dataUrl, index) { + const markdown = managedUrls.map(function(managedUrl, index) { const file = acceptedFiles[index]; const altText = typeof settings.altText === 'string' && acceptedFiles.length === 1 ? (settings.altText.trim() || 'alt text') : getLocalImageAltText(file, source, index, acceptedFiles.length); if (typeof settings.markdownFactory === 'function') { - return settings.markdownFactory(dataUrl, altText, file, index, acceptedFiles.length); + return settings.markdownFactory(managedUrl, altText, file, index, acceptedFiles.length); } - return '![' + altText + '](' + dataUrl + ')'; + return '![' + altText + '](' + managedUrl + ')'; }).join('\n\n'); - const nextValue = originalValue.slice(0, start) + markdown + originalValue.slice(end); - if (!canPersistImageInsertion(targetTabId, nextValue)) { - alert('These images would exceed safe local browser storage. Insert fewer images, use smaller files, or use external image URLs.'); - return 0; - } replaceEditorRange(start, end, markdown, start + markdown.length, start + markdown.length); didInsert = true; const persisted = await persistImageInsertion(targetTabId); - if (!persisted) throw new Error('The browser could not save the embedded image.'); - announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' inserted and saved with the document.'); + if (!persisted) throw new Error('The browser could not save the short image link.'); + announceToScreenReader(acceptedFiles.length + ' image' + (acceptedFiles.length === 1 ? '' : 's') + ' uploaded and inserted with short links.'); return acceptedFiles.length; } catch (error) { if (didInsert) { @@ -13381,11 +13493,13 @@ ${selector} .arrowheadPath { replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } - function setProcessing(processing) { + function setProcessing(processing, stage) { isProcessing = processing; modal.setAttribute('aria-busy', processing ? 'true' : 'false'); confirmBtn.disabled = processing; - confirmBtn.textContent = processing ? translateUiString('Preparing') + '…' : confirmButtonLabel; + confirmBtn.textContent = processing + ? translateUiString(stage === 'uploading' ? 'Uploading' : 'Preparing') + '...' + : confirmButtonLabel; cancelBtn.disabled = processing; uploadOption.disabled = processing; urlOption.disabled = processing; @@ -13402,6 +13516,9 @@ ${selector} .arrowheadPath { selectionStart: start, selectionEnd: end, altText: altInput.value, + onStage: function(stage) { + setProcessing(true, stage); + }, markdownFactory: function(dataUrl) { return buildImageMarkdown(dataUrl); } @@ -20631,7 +20748,7 @@ ${selector} .arrowheadPath { if (imageFiles.length) { const insertedCount = await insertImageFilesIntoEditor(imageFiles, { source: 'drop' }); handledCount += insertedCount; - if (!insertedCount && !markdownFiles.length) { + if (!insertedCount && !markdownFiles.length && (!hasActiveOpenDocument() || !canMutateEditor())) { alert('Open an editable Markdown file before inserting images.'); } } diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 71e0ea76..ff0ee014 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -75,6 +75,8 @@ When running inside Neutralino, dynamic library URLs are rewritten to local `/li | Server share threshold | 3,000 bytes | | Stored Share Snapshot max content | 8,000,000 characters | | Stored Share Snapshot TTL | 90 days | +| Managed image max source file | 25 MiB | +| Managed image max optimized payload | 300 KiB | | Live Share max participants | 64 | | Live Share max message | 8 MB | | STL source limit | 2 MiB | @@ -129,7 +131,7 @@ class_name = "LiveRoom" script_name = "markdown-viewer-live-room" ``` -`SHARE_KV` stores large Share Snapshot records for 90 days. `LIVE_ROOMS` routes Live Share WebSocket rooms to Durable Objects. Share Snapshot and Live Share are separate systems. +`SHARE_KV` stores large Share Snapshot records for 90 days and content-addressed managed images without an automatic expiry. The two record types use separate key prefixes. `LIVE_ROOMS` routes Live Share WebSocket rooms to Durable Objects. Share Snapshot, managed image storage, and Live Share are separate data paths. `wrangler.live-room.toml` deploys `workers/live-room-worker.js` with the `LiveRoom` Durable Object migration. @@ -144,6 +146,16 @@ script_name = "markdown-viewer-live-room" Responses set `Cache-Control: no-store` and vary CORS by request origin. The allowed origins are the production app, `null`, and localhost/127.0.0.1 development origins; unsupported origins receive `403`. Stored records contain content, mode, title, creation time, size, and a hash of the creator deletion token. The token is returned only when the snapshot is created. Invalid ids, missing content, oversized content, invalid deletion tokens, missing KV binding, and unknown routes return JSON errors. +## Managed Image API + +`functions/api/image/[[id]].js` supports: + +- `POST /api/image` to validate, deduplicate, and permanently store an optimized raster image. +- `GET /api/image/` and `HEAD /api/image/` to serve the image through its content-addressed public URL. +- `OPTIONS` for CORS preflight. + +Uploads accept AVIF, BMP, GIF, JPEG, PNG, and WebP data URLs up to 300 KiB after client-side optimization. The API validates both the declared media type and file signature, derives an unguessable 24-character id from SHA-256 content, and stores the record under a managed-image key prefix in `SHARE_KV`. Duplicate content returns the existing id. Image responses are public, immutable, cross-origin raster responses; possession of the URL is sufficient to view an image. + ## Live Room API `functions/live-room/[[room]].js` supports WebSocket upgrades only. It validates the WebSocket `Origin`, room and secret length, requires `LIVE_ROOMS`, and forwards to a Durable Object chosen by `roomName + ":" + secret`. The Durable Object authenticates host, edit, and view capabilities, filters message types by role, and enforces the participant/message limits. diff --git a/wiki/Contributing.md b/wiki/Contributing.md index b8646af6..008cd0dd 100644 --- a/wiki/Contributing.md +++ b/wiki/Contributing.md @@ -36,11 +36,12 @@ Run `npm run setup` or `node prepare.js` after changing root assets that the des ## Cloudflare Features -Stored Share Snapshot requires `SHARE_KV`. Live Share requires `LIVE_ROOMS` and the `LiveRoom` Durable Object. +Managed images and stored Share Snapshot require `SHARE_KV`. Live Share requires `LIVE_ROOMS` and the `LiveRoom` Durable Object. When changing share or live behavior, update: - `script.js` +- `functions/api/image/[[id]].js` - `functions/api/share/[[id]].js` - `functions/live-room/[[room]].js` - `workers/live-room-worker.js` @@ -104,6 +105,7 @@ Please do not open public issues for vulnerabilities. Use GitHub Security Adviso | `preview-worker.js` | Worker Markdown rendering path. | | `styles.css` | Layout, themes, renderer styles, modals, responsive UI. | | `sw.js` | PWA/service-worker cache behavior. | +| `functions/api/image/[[id]].js` | Content-addressed managed image API. | | `functions/api/share/[[id]].js` | Stored Share Snapshot API. | | `functions/live-room/[[room]].js` | Cloudflare Pages Live Share WebSocket entry. | | `workers/live-room-worker.js` | Live Share Durable Object relay. | diff --git a/wiki/FAQ.md b/wiki/FAQ.md index e40b5a21..c185fc38 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -30,6 +30,7 @@ Normal typing, previewing, local file import, tab autosave, and most exports hap - GitHub import contacts GitHub. - Remote diagram renderers can receive diagram source. +- Pasting, dropping, or uploading a device image sends an optimized raster copy to managed image storage after first-use consent. - Large Share Snapshot links upload a temporary copy to Cloudflare KV. - Live Share relays real-time updates through Cloudflare Durable Objects. - External images, links, map tiles, and CDN libraries can be requested by the browser. @@ -52,6 +53,10 @@ They are bearer links. Anyone with the link can open the snapshot. Large stored Small snapshots keep compressed content inside the URL hash. Large snapshots use `/api/share`, which stores content, mode, title, creation time, and size in Cloudflare KV for 90 days. Editable snapshots are not collaborative; they only let the recipient edit their opened copy. +### Are uploaded image links private? + +No. Device images are optimized and uploaded only after first-use consent, then referenced by a short content-addressed HTTPS link. The id is difficult to guess, but anyone who receives the image URL can view it. Managed images do not currently expire automatically. Duplicate image content reuses the same link, and legacy inline raster data can be converted to managed links after the same consent. + ### Is Live Share saved permanently? No. Live Share uses a temporary Cloudflare Durable Object room as a relay. It does not write the live document to KV or a database. While active, the room relays document updates, review comments and suggestions, display names, presence, cursors, sync messages, leave messages, and session-end messages. @@ -123,7 +128,7 @@ Yes, with limits. - The web/PWA build can work offline after the app shell and CDN libraries have been loaded and cached. - First use of CDN-based libraries still requires network access unless they are already cached. - The prepared desktop build bundles external libraries into `resources/libs` and points dynamic library loading there. -- Features that inherently use the network, such as GitHub import, stored Share Snapshot, Live Share, remote diagram rendering, and external images, still need connectivity. +- Features that inherently use the network, such as managed image upload, GitHub import, stored Share Snapshot, Live Share, remote diagram rendering, and external images, still need connectivity. ### Can I open `index.html` directly? diff --git a/wiki/Features.md b/wiki/Features.md index d59a2064..56d1ea1e 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -238,9 +238,11 @@ Local file import: Image insertion: - Uploading from the image dialog, pasting from the clipboard, and dropping an image all use the same insertion pipeline. -- Small raster files are embedded directly; larger images are resized and converted to WebP with a bounded embedded size. -- Embedded image data is stored inside the Markdown, so it survives refreshes and is included in Share Snapshot and Live Share document content. -- Individual source images are limited to 25 MiB, and the app rejects an insertion before it would exceed a conservative browser-storage budget. +- Small raster files retain their safe raster format; larger images are resized and converted to WebP with a bounded upload size. +- After first-use consent, optimized images are stored in Cloudflare KV under a content-derived id and inserted as short HTTPS URLs. Identical content reuses the same id. +- Anyone with a managed image URL can retrieve the image. The URL is unguessable but is not an access-control boundary. +- Existing inline base64 raster images are detected when a normal document opens and can be converted to managed short links without changing alt text or titles. +- Individual source images are limited to 25 MiB and managed image payloads are limited to 300 KiB after optimization. GitHub import: @@ -359,7 +361,7 @@ Implementation: Limits: -- A live message can be at most 8 MB so optimized embedded images can synchronize with the Markdown. +- A live message can be at most 8 MB. Managed images synchronize as short HTTPS links rather than binary document content. - A live room can have at most 64 WebSocket participants. - Participant presence is considered stale after 45 seconds without updates. - Join waits up to 8 seconds for initial room state before showing an expired/unavailable room message. @@ -515,6 +517,7 @@ Security limitations: | Comments and suggestions | Only during Live Share | Normal saved tabs plus temporary Live Share relay state | Excluded from document exports and Share Snapshot; synchronized between active Live Share participants. | | Private mode | No | No document-state persistence | Clears existing document state when enabled and prevents normal document-state writes until disabled. | | Local file import | No | Current tab/workspace | Reads selected files only. | +| Managed image upload | Yes, after first-use consent | Cloudflare KV, content-addressed | Publicly retrievable by its unguessable HTTPS URL; raster formats only, 300 KiB optimized limit. | | Markdown/HTML/PDF/PNG export | No, except remote assets already referenced | User download location | Browser may request external images/fonts used by content. | | GitHub import | Yes | GitHub API/raw URLs | Public repos only; no token flow. | | Emoji lookup | Yes | GitHub emoji API response in memory | Used for shortcode picker/lookup. | @@ -529,7 +532,7 @@ Security limitations: - Browser storage quotas can reject very large saved workspaces. - The GitHub importer shows a maximum of 30 Markdown files. -- Stored Share Snapshot content is limited to 8,000,000 characters so optimized embedded images can travel with the Markdown. +- Stored Share Snapshot content is limited to 8,000,000 characters. Managed images remain separate and travel as short HTTPS links. - STL source is limited to 2 MiB and parsed geometry to 300,000 vertices. - Legacy raster PDF and PNG exports can fail on extremely tall documents because canvas size and memory are browser-limited. - Remote renderer availability depends on third-party services and network conditions. diff --git a/wiki/Installation.md b/wiki/Installation.md index db1435ae..8eac834e 100644 --- a/wiki/Installation.md +++ b/wiki/Installation.md @@ -9,7 +9,7 @@ Markdown Viewer is a browser-based Markdown editor and viewer that can run as a | Local web | Modern browser and a local HTTP server. | | PWA/offline web | HTTPS or localhost for Service Worker support. | | Docker | Docker Engine and port access to the container. | -| Cloudflare sharing/live | Cloudflare Pages, `SHARE_KV`, and `LIVE_ROOMS` Durable Object bindings. | +| Cloudflare sharing/images/live | Cloudflare Pages, `SHARE_KV`, and `LIVE_ROOMS` Durable Object bindings. | | Desktop build | Node.js/npm, Neutralino binaries, and internet access during setup/prepare. | Do not rely on `file://` for normal use. Web Workers and Service Workers can be blocked from local files. @@ -30,7 +30,7 @@ npx serve . -p 8080 Open `http://localhost:8080`. -This runs the editor, split live preview, sync scrolling, local storage, local `.md` file imports, exports, PWA registration, and CDN-loaded renderers. Cloudflare-only features such as stored Share Snapshot and Live Share require their matching deployed endpoints. +This runs the editor, split live preview, sync scrolling, local storage, local `.md` file imports, exports, PWA registration, and CDN-loaded renderers. The default local client sends consented managed-image uploads to the production image API. Other Cloudflare-only features such as stored Share Snapshot and Live Share require their matching deployed endpoints. ## Docker @@ -72,8 +72,9 @@ Serve at least these root files: - `manifest.json` - `assets/` -For Cloudflare Pages with stored Share Snapshot and Live Share, also deploy: +For Cloudflare Pages with managed images, stored Share Snapshot, and Live Share, also deploy: +- `functions/api/image/[[id]].js` - `functions/api/share/[[id]].js` - `functions/live-room/[[room]].js` - `workers/live-room-worker.js` @@ -84,7 +85,7 @@ If you host under a sub-path, test worker, service-worker, manifest, and dynamic ## Cloudflare Setup -Share Snapshot storage needs a KV namespace bound as `SHARE_KV`. +Managed image and Share Snapshot storage need a KV namespace bound as `SHARE_KV`. Their records use separate key prefixes. Live Share needs a Durable Object binding named `LIVE_ROOMS` using the `LiveRoom` class from `workers/live-room-worker.js`. @@ -133,7 +134,7 @@ Web/PWA: - First load requires the app shell and any needed CDN libraries. - After caching, the app shell and previously fetched CDN libraries can work offline. -- Features that require live network access still need it: GitHub import, stored Share Snapshot, Live Share, remote diagram rendering, external images, and map tiles. +- Features that require live network access still need it: managed image upload, GitHub import, stored Share Snapshot, Live Share, remote diagram rendering, external images, and map tiles. Desktop: diff --git a/wiki/Live-Share-Cloudflare.md b/wiki/Live-Share-Cloudflare.md index 3a21982a..665334d5 100644 --- a/wiki/Live-Share-Cloudflare.md +++ b/wiki/Live-Share-Cloudflare.md @@ -84,7 +84,7 @@ Live Share does not write document content to Cloudflare KV or a database. State ## Required Configuration -`wrangler.toml` binds `LIVE_ROOMS` for the Pages project and binds `SHARE_KV` for Share Snapshot. `wrangler.live-room.toml` deploys the standalone Durable Object worker: +`wrangler.toml` binds `LIVE_ROOMS` for the Pages project and binds `SHARE_KV` for Share Snapshot plus managed image records. `wrangler.live-room.toml` deploys the standalone Durable Object worker: ```toml name = "markdown-viewer-live-room" @@ -100,4 +100,4 @@ tag = "v1" new_sqlite_classes = ["LiveRoom"] ``` -Share Snapshot uses `SHARE_KV`; Live Share uses `LIVE_ROOMS`. They should not be described as the same storage path. +Share Snapshot and managed images use separate key prefixes in `SHARE_KV`; Live Share uses `LIVE_ROOMS`. They should not be described as the same storage path. diff --git a/wiki/Localization.md b/wiki/Localization.md index 6017c5e8..a7a1032a 100644 --- a/wiki/Localization.md +++ b/wiki/Localization.md @@ -82,7 +82,7 @@ Add new keys to every locale when introducing new visible UI text. If a key is m ## Contributor Checklist -- Regenerate interface catalogs from the repository root with `node generate-ui-locales.mjs`. +- Regenerate interface catalogs from the repository root with `node assets/i18n/generate-ui-locales.mjs`. - Update `I18N_DICTS` for all supported locales. - Update desktop resources by running the desktop prepare step. - Check desktop and mobile menus. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 3e629d47..a32440c1 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -91,7 +91,7 @@ Scope matching uses Marked's lexer and is best-effort for unusual Markdown. Use Import > From files, the mobile import button, or drag and drop to open local Markdown files. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. -Paste an image from the clipboard or drop an image file into the app to insert it at the current editor cursor as Markdown image syntax. +Paste an image from the clipboard, drop an image file, or use the image dialog to insert it at the current editor cursor. The app optimizes the raster image and uploads it to managed public image storage after first-use consent, then inserts a short HTTPS Markdown image link. Anyone with that unguessable image URL can view it. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. - Supported file types are `.md`, `.markdown`, and `text/markdown`. - Extension matching is case-insensitive. @@ -152,7 +152,7 @@ Use Live Share when you want a temporary real-time room. Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not store the document in KV or a database. The invite URL contains a room id, room secret, access role/capability, and title, not the full document body. The server authenticates host, editable, and view-only capabilities and filters message types by role. Markdown and Review data use separate Yjs documents, so view-only participants can synchronize comments and suggestions without being allowed to edit Markdown. -Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages so optimized embedded images can synchronize with the Markdown. +Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages. Managed images travel as short HTTPS links rather than binary Live Share messages. ## Rendering Diagrams, Maps, Math, STL, and ABC Notation From cb28a9465c6a7069fc5d6ba69d54f1fd050431fa Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 22 Jul 2026 22:01:07 +0530 Subject: [PATCH 04/10] fix(images): expire managed links after 90 days --- CHANGELOG.md | 2 +- README.md | 4 +-- assets/i18n/de.json | 8 ++++-- assets/i18n/en.json | 6 ++-- assets/i18n/es.json | 8 ++++-- assets/i18n/fr.json | 8 ++++-- assets/i18n/it.json | 8 ++++-- assets/i18n/ja.json | 8 ++++-- assets/i18n/ko.json | 8 ++++-- assets/i18n/pl.json | 8 ++++-- assets/i18n/pt.json | 8 ++++-- assets/i18n/ru.json | 8 ++++-- assets/i18n/tr.json | 8 ++++-- assets/i18n/tw.json | 8 ++++-- assets/i18n/uk.json | 8 ++++-- assets/i18n/zh.json | 8 ++++-- desktop-app/resources/index.html | 2 +- desktop-app/resources/js/script.js | 2 +- functions/api/image/[[id]].js | 44 ++++++++++++++++++++++-------- index.html | 2 +- script.js | 2 +- wiki/Configuration.md | 5 ++-- wiki/FAQ.md | 2 +- wiki/Features.md | 3 +- wiki/Usage-Guide.md | 4 +-- 25 files changed, 117 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2c9369..ee813f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Non-code commits (documentation, planning, README-only updates) are excluded. ## Unreleased -- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized and stored behind short content-addressed links. Existing embedded raster images can be converted automatically after consent, and short links remain usable across refreshes, Snapshot, and Live Share. +- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized and stored behind short 90-day content-addressed links. Existing embedded raster images can be converted automatically after consent, and short links remain usable across refreshes, Snapshot, and Live Share until expiry. ## v3.9.3 diff --git a/README.md b/README.md index 8ddb3a1c..8e2a0807 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. - Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, precise drag-and-drop moves, hover-to-expand folders, and edge auto-scrolling. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. -- Paste, upload, or drop image files to insert optimized, content-addressed HTTPS image links at the editor cursor. The app explains on first use that managed images are publicly retrievable by their unguessable link; links remain short across refreshes, Share Snapshot, Live Share, and Markdown export. Existing inline raster data can be converted after the same consent. +- Paste, upload, or drop image files to insert optimized, content-addressed HTTPS image links at the editor cursor. The app explains on first use that managed images are publicly retrievable by their unguessable link and expire after 90 days; links remain short across refreshes, Share Snapshot, Live Share, and Markdown export until expiry. Existing inline raster data can be converted after the same consent. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. @@ -280,7 +280,7 @@ Network use is user-triggered for features such as consented managed-image uploa - Preview HTML is sanitized before insertion, and exported HTML includes a restrictive CSP plus SRI metadata for its external assets. - Secret Workspace derives a local encryption key from the user's password with PBKDF2-SHA-256 and encrypts its files and folder names with AES-GCM. The key is kept only for the unlocked browser session, and forgotten passwords cannot be recovered. - Cloudflare Pages deployments use `_headers` for CSP, clickjacking protection, referrer and permissions policies, and no-sniff protection; sensitive paths are redirected to 404 responses. -- Managed image and stored Share Snapshot API uploads are limited to the production app, previews, `null`, and local development origins. Managed images are public through unguessable immutable links; stored snapshot responses are `no-store`, and snapshot creators receive a deletion token from the API. +- Managed image and stored Share Snapshot API uploads are limited to the production app, previews, `null`, and local development origins. Managed images are public through unguessable immutable links for 90 days; stored snapshot responses are `no-store`, and snapshot creators receive a deletion token from the API. - STL rendering rejects oversized sources, non-finite geometry, and excessive vertex counts before WebGL rendering. - The Neutralino desktop build removes the default `os.execCommand` exposure and keeps native APIs on an explicit allowlist. See the [security model](wiki/Features.md#security-model) and [configuration reference](wiki/Configuration.md#share-api). diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 062a955e..589253a8 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -1027,7 +1027,6 @@ "Collapse": "Reduzieren", "converted to short links.": "in Kurzlinks umgewandelt.", "Converting embedded images to short links.": "Eingebettete Bilder in Kurzlinks umwandeln.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Gerätebilder werden optimiert und hochgeladen, um einen kurzen öffentlichen Link zu erstellen. Jeder mit der Bild-URL kann es ansehen.", "Drop Markdown files or images to add them.": "Legen Sie Markdown-Dateien oder Bilder ab, um sie hinzuzufügen.", "Drop to import or insert": "Zum Importieren oder Einfügen ablegen", "embedded image": "eingebettetes Bild", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Öffnen Sie eine bearbeitbare Markdown-Datei, bevor Sie Bilder einfügen.", "The image could not be inserted.": "Das Bild konnte nicht eingefügt werden.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Um kurze dauerhafte Links zu erstellen, werden Bilder optimiert und in den öffentlichen Bildspeicher Markdown Viewer hochgeladen. Jeder mit einer Bild-URL kann es ansehen. Weitermachen?", "uploaded and inserted with short links.": "hochgeladen und mit Kurzlinks eingefügt.", - "Uploading": "Hochladen" + "Uploading": "Hochladen", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Gerätebilder werden optimiert und hochgeladen, um einen kurzen öffentlichen Link zu erstellen, der nach 90 Tagen abläuft. Bis dahin kann jeder mit der Bild-URL es ansehen.", + "Mermaid diagram": "Meerjungfrau-Diagramm", + "Mermaid diagram actions": "Meerjungfrau-Diagrammaktionen", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Um Kurzlinks zu erstellen, werden Bilder optimiert und für bis zu 90 Tage in den öffentlichen Bildspeicher Markdown Viewer hochgeladen. Jeder mit einer Bild-URL kann es ansehen. Weitermachen?" } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index efa923b3..c7d2230e 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -258,7 +258,7 @@ "Destination": "Destination", "details go here.": "details go here.", "Developed and maintained by": "Developed and maintained by", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.", "Diagram and more": "Diagram and more", "Diagram Categories": "Diagram Categories", "Diagram source code...": "Diagram source code...", @@ -576,6 +576,8 @@ "Menu": "Menu", "Mermaid blocks only": "Mermaid blocks only", "Mermaid could not be rendered. Check the diagram syntax and retry.": "Mermaid could not be rendered. Check the diagram syntax and retry.", + "Mermaid diagram": "Mermaid diagram", + "Mermaid diagram actions": "Mermaid diagram actions", "meter, composed by": "meter, composed by", "Min Read": "Min Read", "Mind Maps": "Mind Maps", @@ -952,7 +954,7 @@ "Title": "Title", "Title case": "Title case", "to build lists, and triple backticks for code blocks.": "to build lists, and triple backticks for code blocks.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?", "to stay within the": "to stay within the", "Toggle Dock Mode": "Toggle Dock Mode", "Toggle Floating Mode": "Toggle Floating Mode", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index adea6c54..55c4ac89 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -1027,7 +1027,6 @@ "Collapse": "Colapso", "converted to short links.": "convertido a enlaces cortos.", "Converting embedded images to short links.": "Conversión de imágenes incrustadas en enlaces cortos.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Las imágenes del dispositivo se optimizan y se cargan para crear un enlace público breve. Cualquiera que tenga la URL de la imagen puede verla.", "Drop Markdown files or images to add them.": "Suelta Markdown archivos o imágenes para agregarlos.", "Drop to import or insert": "Soltar para importar o insertar", "embedded image": "imagen incrustada", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Abra un archivo Markdown editable antes de insertar imágenes.", "The image could not be inserted.": "No se pudo insertar la imagen.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Para crear enlaces cortos persistentes, las imágenes se optimizan y se cargan en el almacenamiento público de imágenes Markdown Viewer. Cualquiera que tenga una URL de imagen puede verla. ¿Continuar?", "uploaded and inserted with short links.": "subido e insertado con enlaces cortos.", - "Uploading": "Subiendo" + "Uploading": "Subiendo", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Las imágenes del dispositivo se optimizan y se cargan para crear un enlace público breve que caduca después de 90 días. Cualquiera que tenga la URL de la imagen podrá verla hasta entonces.", + "Mermaid diagram": "Diagrama de sirena", + "Mermaid diagram actions": "Acciones del diagrama de sirena", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Para crear enlaces cortos, las imágenes se optimizan y se cargan en el almacenamiento público de imágenes Markdown Viewer por hasta 90 días. Cualquiera que tenga una URL de imagen puede verla. ¿Continuar?" } diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 24564a3e..a7e450c9 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -1027,7 +1027,6 @@ "Collapse": "Réduire", "converted to short links.": "converti en liens courts.", "Converting embedded images to short links.": "Conversion d'images intégrées en liens courts.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Les images des appareils sont optimisées et téléchargées pour créer un court lien public. Toute personne disposant de l’URL de l’image peut la voir.", "Drop Markdown files or images to add them.": "Déposez les fichiers ou images Markdown pour les ajouter.", "Drop to import or insert": "Déposer pour importer ou insérer", "embedded image": "image intégrée", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Ouvrez un fichier Markdown modifiable avant d'insérer des images.", "The image could not be inserted.": "L'image n'a pas pu être insérée.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Pour créer des liens persistants courts, les images sont optimisées et téléchargées vers le stockage d'images public Markdown Viewer. Toute personne disposant d’une URL d’image peut la voir. Continuer?", "uploaded and inserted with short links.": "téléchargé et inséré avec des liens courts.", - "Uploading": "Téléchargement" + "Uploading": "Téléchargement", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Les images des appareils sont optimisées et téléchargées pour créer un court lien public qui expire après 90 jours. D’ici là, toute personne disposant de l’URL de l’image peut la voir.", + "Mermaid diagram": "Diagramme de la sirène", + "Mermaid diagram actions": "Actions du diagramme de sirène", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Pour créer des liens courts, les images sont optimisées et téléchargées sur le stockage d'images public Markdown Viewer pendant 90 jours maximum. Toute personne disposant d’une URL d’image peut la voir. Continuer?" } diff --git a/assets/i18n/it.json b/assets/i18n/it.json index a752d846..456cda7b 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -1027,7 +1027,6 @@ "Collapse": "Comprimi", "converted to short links.": "convertito in collegamenti brevi.", "Converting embedded images to short links.": "Conversione di immagini incorporate in collegamenti brevi.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Le immagini del dispositivo vengono ottimizzate e caricate per creare un breve collegamento pubblico. Chiunque abbia l'URL dell'immagine può visualizzarla.", "Drop Markdown files or images to add them.": "Rilascia Markdown file o immagini per aggiungerli.", "Drop to import or insert": "Rilascia per importare o inserire", "embedded image": "immagine incorporata", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Apri un file modificabile Markdown prima di inserire le immagini.", "The image could not be inserted.": "Impossibile inserire l'immagine.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Per creare collegamenti brevi e persistenti, le immagini vengono ottimizzate e caricate nell'archivio pubblico di immagini Markdown Viewer. Chiunque abbia un URL di immagine può visualizzarlo. Continuare?", "uploaded and inserted with short links.": "caricato e inserito con collegamenti brevi.", - "Uploading": "Caricamento" + "Uploading": "Caricamento", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Le immagini del dispositivo vengono ottimizzate e caricate per creare un breve collegamento pubblico che scade dopo 90 giorni. Chiunque abbia l'URL dell'immagine potrà visualizzarla fino ad allora.", + "Mermaid diagram": "Schema della sirena", + "Mermaid diagram actions": "Azioni del diagramma della sirena", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Per creare collegamenti brevi, le immagini vengono ottimizzate e caricate nell'archivio pubblico di immagini Markdown Viewer per un massimo di 90 giorni. Chiunque abbia un URL di immagine può visualizzarlo. Continuare?" } diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index 6d851555..f4e27b12 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -1027,7 +1027,6 @@ "Collapse": "崩壊", "converted to short links.": "は短いリンクに変換されました。", "Converting embedded images to short links.": "埋め込まれた画像を短いリンクに変換します。", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "デバイス画像は最適化され、短いパブリック リンクを作成するためにアップロードされます。画像のURLがわかれば誰でも閲覧できます。", "Drop Markdown files or images to add them.": "Markdown ファイルまたは画像をドロップして追加します。", "Drop to import or insert": "ドロップしてインポートまたは挿入します", "embedded image": "埋め込み画像", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "画像を挿入する前に、編集可能な Markdown ファイルを開いてください。", "The image could not be inserted.": "画像を挿入できませんでした。", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "短い永続リンクを作成するために、画像が最適化され、Markdown Viewer パブリック画像ストレージにアップロードされます。画像の URL を知っている人は誰でも閲覧できます。続く?", "uploaded and inserted with short links.": "がアップロードされ、短いリンクが挿入されました。", - "Uploading": "アップロード中" + "Uploading": "アップロード中", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "デバイス イメージは最適化され、90 日後に期限切れになる短いパブリック リンクを作成するようにアップロードされます。それまでは、画像の URL を知っている人は誰でも閲覧できます。", + "Mermaid diagram": "マーメイドダイアグラム", + "Mermaid diagram actions": "マーメイドダイアグラムのアクション", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "短いリンクを作成するために、画像は最適化され、Markdown Viewer パブリック画像ストレージに最大 90 日間アップロードされます。画像の URL を知っている人は誰でも閲覧できます。続く?" } diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index 96c74b39..e8f9f0f6 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -1027,7 +1027,6 @@ "Collapse": "접기", "converted to short links.": "이 짧은 링크로 변환되었습니다.", "Converting embedded images to short links.": "삽입된 이미지를 짧은 링크로 변환하는 중입니다.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "장치 이미지가 최적화되어 업로드되어 짧은 공개 링크가 생성됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다.", "Drop Markdown files or images to add them.": "Markdown 파일이나 이미지를 드롭하여 추가하세요.", "Drop to import or insert": "가져오기 또는 삽입하려면 드롭하세요.", "embedded image": "삽입된 이미지", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "이미지를 삽입하기 전에 편집 가능한 Markdown 파일을 엽니다.", "The image could not be inserted.": "이미지를 삽입할 수 없습니다.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "짧은 영구 링크를 생성하기 위해 이미지가 최적화되어 Markdown Viewer 공개 이미지 저장소에 업로드됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다. 계속하다?", "uploaded and inserted with short links.": "짧은 링크와 함께 업로드 및 삽입되었습니다.", - "Uploading": "업로드 중" + "Uploading": "업로드 중", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "장치 이미지는 90일 후에 만료되는 짧은 공개 링크를 생성하기 위해 최적화 및 업로드됩니다. 그 전까지는 이미지 URL이 있는 사람은 누구나 볼 수 있습니다.", + "Mermaid diagram": "인어 다이어그램", + "Mermaid diagram actions": "인어 다이어그램 액션", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "짧은 링크를 만들기 위해 이미지가 최적화되어 최대 90일 동안 Markdown Viewer 공개 이미지 저장소에 업로드됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다. 계속하다?" } diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index f37f3aef..95da0e4f 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -1027,7 +1027,6 @@ "Collapse": "Zwiń", "converted to short links.": "przekonwertowane na krótkie linki.", "Converting embedded images to short links.": "Konwersja osadzonych obrazów na krótkie linki.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Obrazy urządzeń są optymalizowane i przesyłane w celu utworzenia krótkiego łącza publicznego. Każdy, kto zna adres URL obrazu, może go wyświetlić.", "Drop Markdown files or images to add them.": "Usuń Markdown plików lub obrazów, aby je dodać.", "Drop to import or insert": "Upuść, aby zaimportować lub wstawić", "embedded image": "osadzony obraz", @@ -1042,7 +1041,10 @@ "MD": "Lek", "Open an editable Markdown file before inserting images.": "Otwórz edytowalny plik Markdown przed wstawieniem obrazów.", "The image could not be inserted.": "Nie można wstawić obrazu.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Aby utworzyć krótkie, trwałe łącza, obrazy są optymalizowane i przesyłane do publicznego magazynu obrazów Markdown Viewer. Każdy, kto ma adres URL obrazu, może go wyświetlić. Kontynuować?", "uploaded and inserted with short links.": "przesłano i wstawiono z krótkimi linkami.", - "Uploading": "Przesyłanie" + "Uploading": "Przesyłanie", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Obrazy urządzeń są optymalizowane i przesyłane w celu utworzenia krótkiego łącza publicznego, które wygasa po 90 dniach. Do tego czasu każdy, kto zna adres URL obrazu, będzie mógł go wyświetlić.", + "Mermaid diagram": "Schemat syreny", + "Mermaid diagram actions": "Działania na diagramie syreny", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Aby utworzyć krótkie linki, obrazy są optymalizowane i przesyłane do publicznego magazynu obrazów Markdown Viewer na okres do 90 dni. Każdy, kto ma adres URL obrazu, może go wyświetlić. Kontynuować?" } diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index f8f97b9e..08d24bdb 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -1027,7 +1027,6 @@ "Collapse": "Recolher", "converted to short links.": "convertido em links curtos.", "Converting embedded images to short links.": "Convertendo imagens incorporadas em links curtos.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "As imagens do dispositivo são otimizadas e carregadas para criar um link público curto. Qualquer pessoa com o URL da imagem pode visualizá-la.", "Drop Markdown files or images to add them.": "Solte Markdown arquivos ou imagens para adicioná-los.", "Drop to import or insert": "Solte para importar ou inserir", "embedded image": "imagem incorporada", @@ -1042,7 +1041,10 @@ "MD": "Médico", "Open an editable Markdown file before inserting images.": "Abra um arquivo Markdown editável antes de inserir imagens.", "The image could not be inserted.": "Não foi possível inserir a imagem.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Para criar links curtos e persistentes, as imagens são otimizadas e enviadas para o armazenamento público de imagens Markdown Viewer. Qualquer pessoa com um URL de imagem pode visualizá-la. Continuar?", "uploaded and inserted with short links.": "carregado e inserido com links curtos.", - "Uploading": "Enviando" + "Uploading": "Enviando", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "As imagens do dispositivo são otimizadas e carregadas para criar um link público curto que expira após 90 dias. Qualquer pessoa com o URL da imagem poderá visualizá-la até então.", + "Mermaid diagram": "Diagrama de sereia", + "Mermaid diagram actions": "Ações do diagrama sereia", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Para criar links curtos, as imagens são otimizadas e enviadas para o armazenamento público de imagens Markdown Viewer por até 90 dias. Qualquer pessoa com um URL de imagem pode visualizá-la. Continuar?" } diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index e9521aff..006eca86 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -1027,7 +1027,6 @@ "Collapse": "Свернуть", "converted to short links.": "преобразовано в короткие ссылки.", "Converting embedded images to short links.": "Преобразование встроенных изображений в короткие ссылки.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Изображения устройств оптимизируются и загружаются для создания короткой общедоступной ссылки. Любой, у кого есть URL-адрес изображения, может просмотреть его.", "Drop Markdown files or images to add them.": "Перетащите Markdown файлы или изображения, чтобы добавить их.", "Drop to import or insert": "Отбросьте, чтобы импортировать или вставить", "embedded image": "встроенное изображение", @@ -1042,7 +1041,10 @@ "MD": "Доктор медицины", "Open an editable Markdown file before inserting images.": "Прежде чем вставлять изображения, откройте редактируемый файл Markdown.", "The image could not be inserted.": "Не удалось вставить изображение.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Для создания коротких постоянных ссылок изображения оптимизируются и загружаются в общедоступное хранилище изображений Markdown Viewer. Любой, у кого есть URL-адрес изображения, может просмотреть его. Продолжать?", "uploaded and inserted with short links.": "загружено и вставлено с короткими ссылками.", - "Uploading": "Загрузка" + "Uploading": "Загрузка", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Образы устройств оптимизируются и загружаются для создания короткой общедоступной ссылки, срок действия которой истекает через 90 дней. До этого момента любой, у кого есть URL-адрес изображения, сможет его просмотреть.", + "Mermaid diagram": "Схема русалки", + "Mermaid diagram actions": "Действия на диаграмме русалки", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Для создания коротких ссылок изображения оптимизируются и загружаются в общедоступное хранилище изображений Markdown Viewer на срок до 90 дней. Любой, у кого есть URL-адрес изображения, может просмотреть его. Продолжать?" } diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index 32210455..dc20e13c 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -1027,7 +1027,6 @@ "Collapse": "Daralt", "converted to short links.": "kısa bağlantılara dönüştürüldü.", "Converting embedded images to short links.": "Gömülü görüntüleri kısa bağlantılara dönüştürme.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Cihaz görüntüleri, kısa bir genel bağlantı oluşturmak için optimize edildi ve yüklendi. Resim URL'sine sahip olan herkes onu görüntüleyebilir.", "Drop Markdown files or images to add them.": "Eklemek için Markdown dosya veya görseli bırakın.", "Drop to import or insert": "İçe aktarmak veya eklemek için bırakın", "embedded image": "gömülü görüntü", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Görüntüleri eklemeden önce düzenlenebilir bir Markdown dosyasını açın.", "The image could not be inserted.": "Resim eklenemedi.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Kısa kalıcı bağlantılar oluşturmak için resimler optimize edilir ve Markdown Viewer genel resim deposuna yüklenir. Resim URL'sine sahip olan herkes bunu görüntüleyebilir. Devam etmek?", "uploaded and inserted with short links.": "kısa bağlantılarla yüklendi ve eklendi.", - "Uploading": "Yükleniyor" + "Uploading": "Yükleniyor", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Cihaz görüntüleri, süresi 90 gün sonra dolacak kısa bir genel bağlantı oluşturacak şekilde optimize edilir ve yüklenir. O zamana kadar resmin URL'sine sahip olan herkes resmi görebilir.", + "Mermaid diagram": "Denizkızı diyagramı", + "Mermaid diagram actions": "Denizkızı diyagramı eylemleri", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Kısa bağlantılar oluşturmak için resimler optimize edilir ve 90 güne kadar Markdown Viewer genel resim deposuna yüklenir. Resim URL'sine sahip olan herkes bunu görüntüleyebilir. Devam etmek?" } diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index ddde06b1..b5b2f2e4 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -1027,7 +1027,6 @@ "Collapse": "折疊", "converted to short links.": "轉換為短連結。", "Converting embedded images to short links.": "將嵌入影像轉換為短連結。", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "設備圖像經過最佳化並上傳以建立簡短的公共連結。知道圖像 URL 的任何人都可以查看它。", "Drop Markdown files or images to add them.": "刪除 Markdown 檔案或影像以新增它們。", "Drop to import or insert": "拖曳以匯入或插入", "embedded image": "嵌入影像", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "在插入影像之前開啟可編輯的 Markdown 檔案。", "The image could not be inserted.": "無法插入影像。", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "為了創建短的持久鏈接,圖像經過優化並上傳到 Markdown Viewer 公共圖像存儲。任何擁有圖像 URL 的人都可以查看它。繼續?", "uploaded and inserted with short links.": "已上傳並插入短連結。", - "Uploading": "上傳中" + "Uploading": "上傳中", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "設備圖像經過最佳化並上傳,以創建一個簡短的公共鏈接,該鏈接將在 90 天後過期。在此之前,任何知道該圖像 URL 的人都可以查看該圖像。", + "Mermaid diagram": "美人魚圖", + "Mermaid diagram actions": "人魚圖動作", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "為了創建短鏈接,圖像經過優化並上傳到 Markdown Viewer 公共圖像存儲,保存時間長達 90 天。任何擁有圖像 URL 的人都可以查看它。繼續?" } diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index 751ce49e..16e20081 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -1027,7 +1027,6 @@ "Collapse": "Згорнути", "converted to short links.": "перетворено на короткі посилання.", "Converting embedded images to short links.": "Перетворення вбудованих зображень на короткі посилання.", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "Зображення пристроїв оптимізовано та завантажено для створення короткого загальнодоступного посилання. Будь-хто, хто має URL-адресу зображення, може його переглянути.", "Drop Markdown files or images to add them.": "Перетягніть Markdown файли або зображення, щоб додати їх.", "Drop to import or insert": "Перетягніть, щоб імпортувати або вставити", "embedded image": "вбудоване зображення", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "Відкрийте редагований файл Markdown перед вставленням зображень.", "The image could not be inserted.": "Не вдалося вставити зображення.", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "Для створення коротких постійних посилань зображення оптимізовано та завантажено до загальнодоступного сховища зображень Markdown Viewer. Будь-хто, хто має URL-адресу зображення, може його переглянути. Продовжити?", "uploaded and inserted with short links.": "завантажено та вставлено з короткими посиланнями.", - "Uploading": "Завантаження" + "Uploading": "Завантаження", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Зображення пристроїв оптимізовано та завантажено для створення короткого загальнодоступного посилання, термін дії якого закінчується через 90 днів. Будь-хто, хто має URL-адресу зображення, може переглянути його до того часу.", + "Mermaid diagram": "Діаграма русалки", + "Mermaid diagram actions": "Дії діаграми русалок", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Для створення коротких посилань зображення оптимізуються та завантажуються до загальнодоступного сховища зображень Markdown Viewer на термін до 90 днів. Будь-хто, хто має URL-адресу зображення, може його переглянути. Продовжити?" } diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index fd8d2ca8..65b04427 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -1027,7 +1027,6 @@ "Collapse": "折叠", "converted to short links.": "转换为短链接。", "Converting embedded images to short links.": "将嵌入图像转换为短链接。", - "Device images are optimized and uploaded to create a short public link. Anyone with the image URL can view it.": "设备图像经过优化并上传以创建简短的公共链接。知道图像 URL 的任何人都可以查看它。", "Drop Markdown files or images to add them.": "删除 Markdown 文件或图像以添加它们。", "Drop to import or insert": "拖放以导入或插入", "embedded image": "嵌入图像", @@ -1042,7 +1041,10 @@ "MD": "MD", "Open an editable Markdown file before inserting images.": "在插入图像之前打开可编辑的 Markdown 文件。", "The image could not be inserted.": "无法插入图像。", - "To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?": "为了创建短的持久链接,图像经过优化并上传到 Markdown Viewer 公共图像存储。任何拥有图像 URL 的人都可以查看它。继续?", "uploaded and inserted with short links.": "已上传并插入短链接。", - "Uploading": "上传中" + "Uploading": "上传中", + "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "设备图像经过优化并上传,以创建一个简短的公共链接,该链接将在 90 天后过期。在此之前,任何知道该图像 URL 的人都可以查看该图像。", + "Mermaid diagram": "美人鱼图", + "Mermaid diagram actions": "人鱼图动作", + "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "为了创建短链接,图像经过优化并上传到 Markdown Viewer 公共图像存储,保存时间长达 90 天。任何拥有图像 URL 的人都可以查看它。继续?" } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index b0219fc5..2198f4d3 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1020,7 +1020,7 @@

Markdown Viewer

diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 436078b7..74969d3b 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -10605,7 +10605,7 @@ ${selector} .arrowheadPath { function requestManagedImageUploadConsent() { if (loadGlobalState().managedImageUploadAcknowledged === true) return true; const accepted = confirm( - 'To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?' + 'To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?' ); if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); return accepted; diff --git a/functions/api/image/[[id]].js b/functions/api/image/[[id]].js index 4a74c86b..ac22af04 100644 --- a/functions/api/image/[[id]].js +++ b/functions/api/image/[[id]].js @@ -1,5 +1,7 @@ const MAX_IMAGE_BYTES = 300 * 1024; const MAX_DATA_URL_CHARS = 420000; +const IMAGE_TTL_SECONDS = 60 * 60 * 24 * 90; +const IMAGE_TTL_MILLISECONDS = IMAGE_TTL_SECONDS * 1000; const IMAGE_ID_PATTERN = /^[A-Za-z0-9_-]{20,32}$/; const IMAGE_KEY_PREFIX = "managed-image-v1:"; const ALLOWED_ORIGINS = new Set([ @@ -152,21 +154,23 @@ export async function onRequest({ request, env, params }) { const imageId = await createImageId(image.mimeType, image.bytes); const storageKey = IMAGE_KEY_PREFIX + imageId; const existing = await env.SHARE_KV.get(storageKey); - if (!existing) { - await env.SHARE_KV.put(storageKey, JSON.stringify({ - version: 1, - mimeType: image.mimeType, - base64: image.base64, - size: image.bytes.byteLength, - createdAt: Date.now() - })); - } + const createdAt = Date.now(); + const expiresAt = createdAt + IMAGE_TTL_MILLISECONDS; + await env.SHARE_KV.put(storageKey, JSON.stringify({ + version: 2, + mimeType: image.mimeType, + base64: image.base64, + size: image.bytes.byteLength, + createdAt, + expiresAt + }), { expirationTtl: IMAGE_TTL_SECONDS }); return jsonResponse({ id: imageId, url: getPublicImageUrl(request, imageId), mimeType: image.mimeType, - size: image.bytes.byteLength + size: image.bytes.byteLength, + expiresAt }, { status: existing ? 200 : 201 }, request); } @@ -182,6 +186,24 @@ export async function onRequest({ request, env, params }) { if (!record || !ALLOWED_IMAGE_TYPES.has(record.mimeType)) { return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); } + const createdAt = Number(record.createdAt); + const storedExpiresAt = Number(record.expiresAt); + const expiresAt = Number.isFinite(storedExpiresAt) && storedExpiresAt > 0 + ? storedExpiresAt + : createdAt + IMAGE_TTL_MILLISECONDS; + if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt)) { + return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); + } + const remainingTtl = Math.floor((expiresAt - Date.now()) / 1000); + if (remainingTtl < 60) { + await env.SHARE_KV.delete(IMAGE_KEY_PREFIX + id); + return jsonResponse({ error: "image expired" }, { status: 404 }, request); + } + if (!Number.isFinite(storedExpiresAt) || storedExpiresAt <= 0) { + record.version = 2; + record.expiresAt = expiresAt; + await env.SHARE_KV.put(IMAGE_KEY_PREFIX + id, JSON.stringify(record), { expirationTtl: remainingTtl }); + } const bytes = decodeBase64(record.base64); if (!bytes || bytes.byteLength > MAX_IMAGE_BYTES || !hasValidImageSignature(record.mimeType, bytes)) { return jsonResponse({ error: "image unavailable" }, { status: 500 }, request); @@ -189,7 +211,7 @@ export async function onRequest({ request, env, params }) { const headers = new Headers({ "Content-Type": record.mimeType, "Content-Length": String(bytes.byteLength), - "Cache-Control": "public, max-age=31536000, immutable", + "Cache-Control": "public, max-age=" + remainingTtl + ", immutable", "Access-Control-Allow-Origin": "*", "Cross-Origin-Resource-Policy": "cross-origin" }); diff --git a/index.html b/index.html index a52e2893..8b93db71 100644 --- a/index.html +++ b/index.html @@ -1113,7 +1113,7 @@

Markdown Viewer

diff --git a/script.js b/script.js index 436078b7..74969d3b 100644 --- a/script.js +++ b/script.js @@ -10605,7 +10605,7 @@ ${selector} .arrowheadPath { function requestManagedImageUploadConsent() { if (loadGlobalState().managedImageUploadAcknowledged === true) return true; const accepted = confirm( - 'To create short persistent links, images are optimized and uploaded to Markdown Viewer public image storage. Anyone with an image URL can view it. Continue?' + 'To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?' ); if (accepted) saveGlobalState({ managedImageUploadAcknowledged: true }); return accepted; diff --git a/wiki/Configuration.md b/wiki/Configuration.md index ff0ee014..1f486919 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -77,6 +77,7 @@ When running inside Neutralino, dynamic library URLs are rewritten to local `/li | Stored Share Snapshot TTL | 90 days | | Managed image max source file | 25 MiB | | Managed image max optimized payload | 300 KiB | +| Managed image TTL | 90 days from the most recent upload of that content | | Live Share max participants | 64 | | Live Share max message | 8 MB | | STL source limit | 2 MiB | @@ -131,7 +132,7 @@ class_name = "LiveRoom" script_name = "markdown-viewer-live-room" ``` -`SHARE_KV` stores large Share Snapshot records for 90 days and content-addressed managed images without an automatic expiry. The two record types use separate key prefixes. `LIVE_ROOMS` routes Live Share WebSocket rooms to Durable Objects. Share Snapshot, managed image storage, and Live Share are separate data paths. +`SHARE_KV` stores large Share Snapshot records and content-addressed managed images for 90 days. Re-uploading identical image content refreshes that image's 90-day expiry. The two record types use separate key prefixes. `LIVE_ROOMS` routes Live Share WebSocket rooms to Durable Objects. Share Snapshot, managed image storage, and Live Share are separate data paths. `wrangler.live-room.toml` deploys `workers/live-room-worker.js` with the `LiveRoom` Durable Object migration. @@ -154,7 +155,7 @@ Responses set `Cache-Control: no-store` and vary CORS by request origin. The all - `GET /api/image/` and `HEAD /api/image/` to serve the image through its content-addressed public URL. - `OPTIONS` for CORS preflight. -Uploads accept AVIF, BMP, GIF, JPEG, PNG, and WebP data URLs up to 300 KiB after client-side optimization. The API validates both the declared media type and file signature, derives an unguessable 24-character id from SHA-256 content, and stores the record under a managed-image key prefix in `SHARE_KV`. Duplicate content returns the existing id. Image responses are public, immutable, cross-origin raster responses; possession of the URL is sufficient to view an image. +Uploads accept AVIF, BMP, GIF, JPEG, PNG, and WebP data URLs up to 300 KiB after client-side optimization. The API validates both the declared media type and file signature, derives an unguessable 24-character id from SHA-256 content, and stores the record under a managed-image key prefix in `SHARE_KV` with a 90-day TTL. Duplicate content returns the existing id and refreshes its TTL. Image responses are public, immutable, cross-origin raster responses until expiry; possession of the URL is sufficient to view an image. ## Live Room API diff --git a/wiki/FAQ.md b/wiki/FAQ.md index c185fc38..35ff5b12 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -55,7 +55,7 @@ Small snapshots keep compressed content inside the URL hash. Large snapshots use ### Are uploaded image links private? -No. Device images are optimized and uploaded only after first-use consent, then referenced by a short content-addressed HTTPS link. The id is difficult to guess, but anyone who receives the image URL can view it. Managed images do not currently expire automatically. Duplicate image content reuses the same link, and legacy inline raster data can be converted to managed links after the same consent. +No. Device images are optimized and uploaded only after first-use consent, then referenced by a short content-addressed HTTPS link. The id is difficult to guess, but anyone who receives the image URL can view it for up to 90 days. Cloudflare KV deletes the image after that TTL, so the link stops rendering. Duplicate image content reuses the same link and refreshes its 90-day expiry; legacy inline raster data can be converted to managed links after the same consent. ### Is Live Share saved permanently? diff --git a/wiki/Features.md b/wiki/Features.md index 56d1ea1e..433a06b3 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -241,6 +241,7 @@ Image insertion: - Small raster files retain their safe raster format; larger images are resized and converted to WebP with a bounded upload size. - After first-use consent, optimized images are stored in Cloudflare KV under a content-derived id and inserted as short HTTPS URLs. Identical content reuses the same id. - Anyone with a managed image URL can retrieve the image. The URL is unguessable but is not an access-control boundary. +- Managed images expire 90 days after their most recent upload. After expiry, the Markdown link remains but the image no longer renders. - Existing inline base64 raster images are detected when a normal document opens and can be converted to managed short links without changing alt text or titles. - Individual source images are limited to 25 MiB and managed image payloads are limited to 300 KiB after optimization. @@ -517,7 +518,7 @@ Security limitations: | Comments and suggestions | Only during Live Share | Normal saved tabs plus temporary Live Share relay state | Excluded from document exports and Share Snapshot; synchronized between active Live Share participants. | | Private mode | No | No document-state persistence | Clears existing document state when enabled and prevents normal document-state writes until disabled. | | Local file import | No | Current tab/workspace | Reads selected files only. | -| Managed image upload | Yes, after first-use consent | Cloudflare KV, content-addressed | Publicly retrievable by its unguessable HTTPS URL; raster formats only, 300 KiB optimized limit. | +| Managed image upload | Yes, after first-use consent | Cloudflare KV, content-addressed, 90-day TTL | Publicly retrievable by its unguessable HTTPS URL until expiry; raster formats only, 300 KiB optimized limit. | | Markdown/HTML/PDF/PNG export | No, except remote assets already referenced | User download location | Browser may request external images/fonts used by content. | | GitHub import | Yes | GitHub API/raw URLs | Public repos only; no token flow. | | Emoji lookup | Yes | GitHub emoji API response in memory | Used for shortcode picker/lookup. | diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index a32440c1..932bb046 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -91,7 +91,7 @@ Scope matching uses Marked's lexer and is best-effort for unusual Markdown. Use Import > From files, the mobile import button, or drag and drop to open local Markdown files. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. -Paste an image from the clipboard, drop an image file, or use the image dialog to insert it at the current editor cursor. The app optimizes the raster image and uploads it to managed public image storage after first-use consent, then inserts a short HTTPS Markdown image link. Anyone with that unguessable image URL can view it. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. +Paste an image from the clipboard, drop an image file, or use the image dialog to insert it at the current editor cursor. The app optimizes the raster image and uploads it to managed public image storage after first-use consent, then inserts a short HTTPS Markdown image link. Anyone with that unguessable image URL can view it for up to 90 days; after expiry the link remains in Markdown but the image stops rendering. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. - Supported file types are `.md`, `.markdown`, and `text/markdown`. - Extension matching is case-insensitive. @@ -152,7 +152,7 @@ Use Live Share when you want a temporary real-time room. Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not store the document in KV or a database. The invite URL contains a room id, room secret, access role/capability, and title, not the full document body. The server authenticates host, editable, and view-only capabilities and filters message types by role. Markdown and Review data use separate Yjs documents, so view-only participants can synchronize comments and suggestions without being allowed to edit Markdown. -Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages. Managed images travel as short HTTPS links rather than binary Live Share messages. +Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 8 MB live messages. Managed images travel as short HTTPS links rather than binary Live Share messages and stop rendering when their 90-day storage TTL expires. ## Rendering Diagrams, Maps, Math, STL, and ABC Notation From 51b62d1fb46b9012b2c488538d0d3e43d2d27a8d Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Wed, 22 Jul 2026 23:44:47 +0530 Subject: [PATCH 05/10] feat(editor): add GIF and video media uploads --- CHANGELOG.md | 2 +- README.md | 6 +- _headers | 2 +- assets/i18n/de.json | 47 ++-- assets/i18n/en.json | 47 ++-- assets/i18n/es.json | 47 ++-- assets/i18n/fr.json | 47 ++-- assets/i18n/it.json | 47 ++-- assets/i18n/ja.json | 47 ++-- assets/i18n/ko.json | 47 ++-- assets/i18n/pl.json | 47 ++-- assets/i18n/pt.json | 47 ++-- assets/i18n/ru.json | 47 ++-- assets/i18n/tr.json | 47 ++-- assets/i18n/tw.json | 47 ++-- assets/i18n/uk.json | 47 ++-- assets/i18n/zh.json | 47 ++-- desktop-app/resources/index.html | 48 ++-- desktop-app/resources/js/script.js | 415 ++++++++++++++++++++++++----- desktop-app/resources/styles.css | 170 +++++++++++- functions/api/image/[[id]].js | 104 +++++--- functions/api/media/[[id]].js | 1 + index.html | 48 ++-- script.js | 415 ++++++++++++++++++++++++----- styles.css | 170 +++++++++++- sw.js | 2 +- wiki/Configuration.md | 25 +- wiki/Contributing.md | 6 +- wiki/FAQ.md | 8 +- wiki/Features.md | 29 +- wiki/Installation.md | 11 +- wiki/Live-Share-Cloudflare.md | 4 +- wiki/Usage-Guide.md | 8 +- 33 files changed, 1648 insertions(+), 484 deletions(-) create mode 100644 functions/api/media/[[id]].js diff --git a/CHANGELOG.md b/CHANGELOG.md index ee813f86..895eda46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Non-code commits (documentation, planning, README-only updates) are excluded. ## Unreleased -- **File and image drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, added a Markdown-style file drag preview, and made uploaded, pasted, and dropped images optimized and stored behind short 90-day content-addressed links. Existing embedded raster images can be converted automatically after consent, and short links remain usable across refreshes, Snapshot, and Live Share until expiry. +- **Media upload and file drag-and-drop:** Replaced the blocking full-window drop overlay with a compact notice, made folder-targeted Markdown imports land in the selected folder, kept untargeted imports at the default workspace root, added hover-to-expand folders and Explorer edge auto-scrolling, and added a Markdown-style file drag preview. Uploaded, pasted, and dropped images now use short 90-day content-addressed links; animated GIFs retain animation, and MP4, WebM, and Ogg videos insert as sanitized playable media. Uploads report progress in the same toast pattern as GitHub imports, user-facing alerts use accessible app toasts, and right-clicking blank Explorer space offers New file and New folder. Existing embedded raster images can still be converted automatically after consent. ## v3.9.3 diff --git a/README.md b/README.md index 8e2a0807..27d8b8b3 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ For the full feature list, details, limitations, and privacy notes, see the [fea - Write plain Markdown in a focused editor while the live preview renders GitHub-Flavored Markdown, syntax highlighting, math, alerts, footnotes, tables, task lists, and sanitized HTML. - Organize up to 50 Markdown files in a closable, responsive left sidebar with a persistent tab-strip toggle, fixed Default and Secret workspaces, one-level folders, search, Recent, Favorites, single-click opening, precise drag-and-drop moves, hover-to-expand folders, and edge auto-scrolling. Secret Workspace content is password-encrypted on the device, and multi-file imports show compact progress without blocking the editor. -- Paste, upload, or drop image files to insert optimized, content-addressed HTTPS image links at the editor cursor. The app explains on first use that managed images are publicly retrievable by their unguessable link and expire after 90 days; links remain short across refreshes, Share Snapshot, Live Share, and Markdown export until expiry. Existing inline raster data can be converted after the same consent. +- Paste, upload, or drop images, animated GIFs, and MP4, WebM, or Ogg videos at the editor cursor. A GitHub-import-style toast reports media upload progress, and managed media uses short content-addressed HTTPS links that remain usable across refreshes, Share Snapshot, Live Share, and Markdown export until their 90-day expiry. Existing inline raster data can be converted after the same first-use consent. - Use WYSIWYG-style toolbar helpers for common Markdown syntax while keeping full control of the plain-text Markdown source. - Preview large documents with debounced rendering and a background worker so typing stays responsive. @@ -273,14 +273,14 @@ Some advanced diagram engines use remote renderers such as PlantUML, Kroki, or m Markdown Viewer is not a cloud workspace. Normal typing, preview rendering, local file import, tab autosave, theme settings, and most exports happen on your device. No login is required, and the app does not implement analytics, telemetry, ads, or tracking cookies. -Network use is user-triggered for features such as consented managed-image upload, GitHub import, remote diagram renderers, Share Snapshot, Live Share, CDN libraries, and external document assets. Private mode in Workspace settings keeps document content, workspace state, and review feedback session-only while editing and review tools remain available. For the full reference, read the [data handling summary](wiki/Features.md#data-handling-summary). +Network use is user-triggered for features such as consented managed-media upload, GitHub import, remote diagram renderers, Share Snapshot, Live Share, CDN libraries, and external document assets. Private mode in Workspace settings keeps document content, workspace state, and review feedback session-only while editing and review tools remain available. For the full reference, read the [data handling summary](wiki/Features.md#data-handling-summary). ## Security and Privacy Controls - Preview HTML is sanitized before insertion, and exported HTML includes a restrictive CSP plus SRI metadata for its external assets. - Secret Workspace derives a local encryption key from the user's password with PBKDF2-SHA-256 and encrypts its files and folder names with AES-GCM. The key is kept only for the unlocked browser session, and forgotten passwords cannot be recovered. - Cloudflare Pages deployments use `_headers` for CSP, clickjacking protection, referrer and permissions policies, and no-sniff protection; sensitive paths are redirected to 404 responses. -- Managed image and stored Share Snapshot API uploads are limited to the production app, previews, `null`, and local development origins. Managed images are public through unguessable immutable links for 90 days; stored snapshot responses are `no-store`, and snapshot creators receive a deletion token from the API. +- Managed media and stored Share Snapshot API uploads are limited to the production app, previews, `null`, and local development origins. Managed images, GIFs, and videos are public through unguessable immutable links for 90 days; stored snapshot responses are `no-store`, and snapshot creators receive a deletion token from the API. - STL rendering rejects oversized sources, non-finite geometry, and excessive vertex counts before WebGL rendering. - The Neutralino desktop build removes the default `os.execCommand` exposure and keeps native APIs on an explicit allowlist. See the [security model](wiki/Features.md#security-model) and [configuration reference](wiki/Configuration.md#share-api). diff --git a/_headers b/_headers index 55e9462b..ec405d02 100644 --- a/_headers +++ b/_headers @@ -1,6 +1,6 @@ /* Strict-Transport-Security: max-age=63072000; includeSubDomains; preload - Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME=' 'sha256-NnbK2LG1LUwYyZ1xgJN7k3oK4oTvHY4o9852VxwNA/o='; script-src-attr 'none'; worker-src 'self'; connect-src 'self' https://markdownviewer.pages.dev https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io wss://markdownviewer.pages.dev; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src-attr 'unsafe-inline'; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data:; manifest-src 'self'; frame-src 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh 'sha256-DgMFO4QE+qqf2xNgeNb5gMKG6BtiiQFniYj21c88yME=' 'sha256-NnbK2LG1LUwYyZ1xgJN7k3oK4oTvHY4o9852VxwNA/o='; script-src-attr 'none'; worker-src 'self'; connect-src 'self' https://markdownviewer.pages.dev https://api.github.com https://raw.githubusercontent.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://kroki.io https://www.plantuml.com https://mermaid.ink https://paulrosen.github.io wss://markdownviewer.pages.dev; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src-attr 'unsafe-inline'; font-src 'self' data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; media-src 'self' blob: data: https:; manifest-src 'self'; frame-src 'none'; upgrade-insecure-requests X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 589253a8..4e35913d 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -63,7 +63,6 @@ "All changes saved": "Alle Änderungen gespeichert", "All workspace folders collapsed.": "Alle Arbeitsbereichsordner wurden minimiert.", "All workspace folders expanded.": "Alle Arbeitsbereichsordner erweitert.", - "Alt Text (used for title)": "Alt-Text (wird für den Titel verwendet)", "Apache License 2.0": "Apache-Lizenz 2.0", "Appearance": "Aussehen", "Application information": "Anwendungsinformationen", @@ -301,7 +300,6 @@ "Export document": "Dokument exportieren", "Export failed:": "Export fehlgeschlagen:", "Export PDF Options": "PDF-Optionen exportieren", - "External Image (URL)": "Externes Bild (URL)", "Faceted Gear Wheel": "Facettiertes Zahnrad", "Failed to copy HTML:": "HTML konnte nicht kopiert werden:", "Failed to copy Markdown:": "Fehler beim Kopieren von Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML-Entitäten", "HTML export failed:": "HTML-Export fehlgeschlagen:", - "Image": "Bild", "Image (.png)": "Bild (.png)", - "Image description": "Bildbeschreibung", "Image generation progress": "Fortschritt der Bildgenerierung", - "Image URL": "Bild-URL", "Implement live preview with GitHub styling": "Live-Vorschau mit GitHub-Stil implementieren", "Import": "Importieren", "Import complete": "Import abgeschlossen", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Inline-Codeblöcke", "Insert": "Einfügen", "Insert Diagram & More": "Diagramm und mehr einfügen", - "Insert image": "Bild einfügen", "Insert link": "Link einfügen", "Insert reference": "Referenz einfügen", "Insert table": "Tabelle einfügen", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Geheimen Arbeitsbereich freischalten", "Unlock workspace": "Arbeitsbereich entsperren", "Unlocking…": "Entsperren…", - "Upload from Device": "Vom Gerät hochladen", "UPPERCASE": "GROSSBUCHSTABEN", "Use": "Verwendung", "Use at least 8 characters.": "Verwenden Sie mindestens 8 Zeichen.", @@ -1027,24 +1020,44 @@ "Collapse": "Reduzieren", "converted to short links.": "in Kurzlinks umgewandelt.", "Converting embedded images to short links.": "Eingebettete Bilder in Kurzlinks umwandeln.", - "Drop Markdown files or images to add them.": "Legen Sie Markdown-Dateien oder Bilder ab, um sie hinzuzufügen.", "Drop to import or insert": "Zum Importieren oder Einfügen ablegen", "embedded image": "eingebettetes Bild", "Embedded images could not be converted. Please try again.": "Eingebettete Bilder konnten nicht konvertiert werden. Bitte versuchen Sie es erneut.", "expanded.": "erweitert.", - "for short links.": "für kurze Links.", "Image conversion was cancelled because the document changed.": "Die Bildkonvertierung wurde abgebrochen, da sich das Dokument geändert hat.", - "Image insertion was cancelled because the document changed.": "Das Einfügen von Bildern wurde abgebrochen, da sich das Dokument geändert hat.", - "Image upload cancelled.": "Bild-Upload abgebrochen.", - "Images larger than 25MB cannot be inserted.": "Bilder, die größer als 25 MB sind, können nicht eingefügt werden.", - "Markdown files and images are supported": "Markdown Dateien und Bilder werden unterstützt", "MD": "MD", "Open an editable Markdown file before inserting images.": "Öffnen Sie eine bearbeitbare Markdown-Datei, bevor Sie Bilder einfügen.", - "The image could not be inserted.": "Das Bild konnte nicht eingefügt werden.", "uploaded and inserted with short links.": "hochgeladen und mit Kurzlinks eingefügt.", - "Uploading": "Hochladen", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Gerätebilder werden optimiert und hochgeladen, um einen kurzen öffentlichen Link zu erstellen, der nach 90 Tagen abläuft. Bis dahin kann jeder mit der Bild-URL es ansehen.", "Mermaid diagram": "Meerjungfrau-Diagramm", "Mermaid diagram actions": "Meerjungfrau-Diagrammaktionen", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Um Kurzlinks zu erstellen, werden Bilder optimiert und für bis zu 90 Tage in den öffentlichen Bildspeicher Markdown Viewer hochgeladen. Jeder mit einer Bild-URL kann es ansehen. Weitermachen?" + "\" aria-label=\"": "\" aria-label=\"", + "cancel": "abbrechen", + "Description": "Beschreibung", + "Dismiss notification": "Benachrichtigung verwerfen", + "Drop Markdown files, images, GIFs, or videos to add them.": "Legen Sie Markdown Dateien, Bilder, GIFs oder Videos ab, um sie hinzuzufügen.", + "External Media (URL)": "Externe Medien (URL)", + "Image, GIF, or video": "Bild, GIF oder Video", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Bilder, GIFs und Videos werden hochgeladen, um einen kurzen öffentlichen Link zu erstellen, der nach 90 Tagen abläuft. Bis dahin kann jeder mit der Medien-URL es sehen. GIF-Limit: 5 MB; Videolimit: 10 MB.", + "Insert image, GIF, or video": "Fügen Sie ein Bild, GIF oder Video ein", + "Markdown files, images, GIFs, and videos are supported": "Markdown Dateien, Bilder, GIFs und Videos werden unterstützt", + "Media description": "Medienbeschreibung", + "media file": "Mediendatei", + "Media file": "Mediendatei", + "Media files larger than 25MB cannot be inserted.": "Mediendateien, die größer als 25 MB sind, können nicht eingefügt werden.", + "Media insertion cancelled": "Medieneinfügung abgebrochen", + "Media insertion was cancelled because the document changed.": "Das Einfügen des Mediums wurde abgebrochen, da sich das Dokument geändert hat.", + "Media upload cancelled.": "Medien-Upload abgebrochen.", + "Media upload complete": "Medien-Upload abgeschlossen", + "Media upload failed": "Medien-Upload fehlgeschlagen", + "Media upload progress": "Fortschritt des Medien-Uploads", + "Media URL": "Medien-URL", + "Notifications": "Benachrichtigungen", + "Preparing upload...": "Upload wird vorbereitet...", + "The document changed during upload.": "Das Dokument wurde während des Hochladens geändert.", + "The media file could not be inserted.": "Die Mediendatei konnte nicht eingefügt werden.", + "Upload Image, GIF, or Video": "Bild, GIF oder Video hochladen", + "Upload media?": "Medien hochladen?", + "Uploading media": "Medien werden hochgeladen", + "Uploading media file": "Mediendatei wird hochgeladen", + "Uploading media files": "Hochladen von Mediendateien" } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index c7d2230e..490fc124 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -11,6 +11,7 @@ ", file location": ", file location", ", locked": ", locked", ", or": ", or", + "\" aria-label=\"": "\" aria-label=\"", "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">": "\" style=\"width:100%; height:100%; display:flex; align-items:center; justify-content:center; overflow:hidden;\">", "{{0}} and {{1}}": "{{0}} and {{1}}", "{{0}} characters": "{{0}} characters", @@ -74,7 +75,6 @@ "All feedback resolved": "All feedback resolved", "All workspace folders collapsed.": "All workspace folders collapsed.", "All workspace folders expanded.": "All workspace folders expanded.", - "Alt Text (used for title)": "Alt Text (used for title)", "Apache License 2.0": "Apache License 2.0", "Appearance": "Appearance", "Application information": "Application information", @@ -101,6 +101,7 @@ "Calculating...": "Calculating...", "Calibration Cube": "Calibration Cube", "Can edit": "Can edit", + "cancel": "cancel", "Cancel": "Cancel", "Cancel feedback": "Cancel feedback", "Cancel Image generation": "Cancel Image generation", @@ -255,10 +256,10 @@ "Delete review item?": "Delete review item?", "Delete selected items": "Delete selected items", "Deployment Nodes": "Deployment Nodes", + "Description": "Description", "Destination": "Destination", "details go here.": "details go here.", "Developed and maintained by": "Developed and maintained by", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.", "Diagram and more": "Diagram and more", "Diagram Categories": "Diagram Categories", "Diagram source code...": "Diagram source code...", @@ -267,6 +268,7 @@ "Digite seu markdown aqui...": "Digite seu markdown aqui...", "Directed Graph": "Directed Graph", "Disable synchronized scrolling": "Disable synchronized scrolling", + "Dismiss notification": "Dismiss notification", "Display name": "Display name", "Document added to favorites.": "Document added to favorites.", "Document block": "Document block", @@ -284,7 +286,7 @@ "Download started": "Download started", "Download SVG": "Download SVG", "Drag files to move": "Drag files to move", - "Drop Markdown files or images to add them.": "Drop Markdown files or images to add them.", + "Drop Markdown files, images, GIFs, or videos to add them.": "Drop Markdown files, images, GIFs, or videos to add them.", "Drop to import or insert": "Drop to import or insert", "Duet Accord": "Duet Accord", "duplicate": "duplicate", @@ -345,7 +347,7 @@ "Export document": "Export document", "Export failed:": "Export failed:", "Export PDF Options": "Export PDF Options", - "External Image (URL)": "External Image (URL)", + "External Media (URL)": "External Media (URL)", "Faceted Gear Wheel": "Faceted Gear Wheel", "Failed to copy HTML:": "Failed to copy HTML:", "Failed to copy Markdown:": "Failed to copy Markdown:", @@ -409,7 +411,6 @@ "folders": "folders", "Folders stay one level deep inside a workspace.": "Folders stay one level deep inside a workspace.", "Folk & Lyrics": "Folk & Lyrics", - "for short links.": "for short links.", "for this block.": "for this block.", "Forgot access key? Reset Secret Workspace": "Forgot access key? Reset Secret Workspace", "Fresh Tomato": "Fresh Tomato", @@ -456,15 +457,11 @@ "HTML": "HTML", "HTML entities": "HTML entities", "HTML export failed:": "HTML export failed:", - "Image": "Image", "Image (.png)": "Image (.png)", "Image conversion was cancelled because the document changed.": "Image conversion was cancelled because the document changed.", - "Image description": "Image description", "Image generation progress": "Image generation progress", - "Image insertion was cancelled because the document changed.": "Image insertion was cancelled because the document changed.", - "Image upload cancelled.": "Image upload cancelled.", - "Image URL": "Image URL", - "Images larger than 25MB cannot be inserted.": "Images larger than 25MB cannot be inserted.", + "Image, GIF, or video": "Image, GIF, or video", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.", "Implement live preview with GitHub styling": "Implement live preview with GitHub styling", "Import": "Import", "Import complete": "Import complete", @@ -487,7 +484,7 @@ "Inline Code Blocks": "Inline Code Blocks", "Insert": "Insert", "Insert Diagram & More": "Insert Diagram & More", - "Insert image": "Insert image", + "Insert image, GIF, or video": "Insert image, GIF, or video", "Insert link": "Insert link", "Insert reference": "Insert reference", "Insert table": "Insert table", @@ -550,7 +547,7 @@ "Markdown document": "Markdown document", "Markdown editor input with live preview": "Markdown editor input with live preview", "Markdown file import progress": "Markdown file import progress", - "Markdown files and images are supported": "Markdown files and images are supported", + "Markdown files, images, GIFs, and videos are supported": "Markdown files, images, GIFs, and videos are supported", "Markdown formatting toolbar for plain-text editing": "Markdown formatting toolbar for plain-text editing", "Markdown Görüntüleyici": "Markdown Görüntüleyici", "Markdown preview only": "Markdown preview only", @@ -573,6 +570,17 @@ "Maximum document limit reached.": "Maximum document limit reached.", "Maximum of": "Maximum of", "MD": "MD", + "Media description": "Media description", + "media file": "media file", + "Media file": "Media file", + "Media files larger than 25MB cannot be inserted.": "Media files larger than 25MB cannot be inserted.", + "Media insertion cancelled": "Media insertion cancelled", + "Media insertion was cancelled because the document changed.": "Media insertion was cancelled because the document changed.", + "Media upload cancelled.": "Media upload cancelled.", + "Media upload complete": "Media upload complete", + "Media upload failed": "Media upload failed", + "Media upload progress": "Media upload progress", + "Media URL": "Media URL", "Menu": "Menu", "Mermaid blocks only": "Mermaid blocks only", "Mermaid could not be rendered. Check the diagram syntax and retry.": "Mermaid could not be rendered. Check the diagram syntax and retry.", @@ -634,6 +642,7 @@ "No resolved feedback": "No resolved feedback", "No results": "No results", "No symbols found.": "No symbols found.", + "Notifications": "Notifications", "Numbered list": "Numbered list", "Object Diagram": "Object Diagram", "Object Instances": "Object Instances", @@ -694,6 +703,7 @@ "Preparing import…": "Preparing import…", "Preparing Mermaid for print...": "Preparing Mermaid for print...", "Preparing repository folder…": "Preparing repository folder…", + "Preparing upload...": "Preparing upload...", "Preserve Case": "Preserve Case", "Preserve Case (Ab)": "Preserve Case (Ab)", "Preview": "Preview", @@ -924,10 +934,11 @@ "Text layout": "Text layout", "Text style": "Text style", "The command-line file could not be opened because the": "The command-line file could not be opened because the", + "The document changed during upload.": "The document changed during upload.", "The file could not be moved because the destination could not be saved.": "The file could not be moved because the destination could not be saved.", - "The image could not be inserted.": "The image could not be inserted.", "The live room could not be joined. Please check the invite link or connection.": "The live room could not be joined. Please check the invite link or connection.", "The Live Share room could not be opened because the": "The Live Share room could not be opened because the", + "The media file could not be inserted.": "The media file could not be inserted.", "The provided URL does not point to a Markdown file.": "The provided URL does not point to a Markdown file.", "The repository folder could not be created.": "The repository folder could not be created.", "The share link could not be generated.": "The share link could not be generated.", @@ -954,7 +965,6 @@ "Title": "Title", "Title case": "Title case", "to build lists, and triple backticks for code blocks.": "to build lists, and triple backticks for code blocks.", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?", "to stay within the": "to stay within the", "Toggle Dock Mode": "Toggle Dock Mode", "Toggle Floating Mode": "Toggle Floating Mode", @@ -979,9 +989,12 @@ "Unlock Secret Workspace": "Unlock Secret Workspace", "Unlock workspace": "Unlock workspace", "Unlocking…": "Unlocking…", - "Upload from Device": "Upload from Device", + "Upload Image, GIF, or Video": "Upload Image, GIF, or Video", + "Upload media?": "Upload media?", "uploaded and inserted with short links.": "uploaded and inserted with short links.", - "Uploading": "Uploading", + "Uploading media": "Uploading media", + "Uploading media file": "Uploading media file", + "Uploading media files": "Uploading media files", "UPPERCASE": "UPPERCASE", "Use": "Use", "Use at least 8 characters.": "Use at least 8 characters.", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 55c4ac89..44f882cf 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -63,7 +63,6 @@ "All changes saved": "Todos los cambios guardados", "All workspace folders collapsed.": "Todas las carpetas del espacio de trabajo colapsaron.", "All workspace folders expanded.": "Todas las carpetas del espacio de trabajo expandidas.", - "Alt Text (used for title)": "Texto alternativo (usado para el título)", "Apache License 2.0": "Licencia Apache 2.0", "Appearance": "Apariencia", "Application information": "Información de la aplicación", @@ -301,7 +300,6 @@ "Export document": "Exportar documento", "Export failed:": "Exportación fallida:", "Export PDF Options": "Opciones de exportación de PDF", - "External Image (URL)": "Imagen externa (URL)", "Faceted Gear Wheel": "Rueda dentada facetada", "Failed to copy HTML:": "No se pudo copiar HTML:", "Failed to copy Markdown:": "No se pudo copiar Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "entidades HTML", "HTML export failed:": "Error al exportar HTML:", - "Image": "Imagen", "Image (.png)": "Imagen (.png)", - "Image description": "Descripción de la imagen", "Image generation progress": "Progreso de generación de imágenes", - "Image URL": "URL de la imagen", "Implement live preview with GitHub styling": "Implementar vista previa en vivo con estilo GitHub", "Import": "Importar", "Import complete": "Importación completa", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Bloques de código en línea", "Insert": "Insertar", "Insert Diagram & More": "Insertar diagrama y más", - "Insert image": "Insertar imagen", "Insert link": "Insertar enlace", "Insert reference": "Insertar referencia", "Insert table": "Insertar tabla", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Desbloquear el espacio de trabajo secreto", "Unlock workspace": "Desbloquear espacio de trabajo", "Unlocking…": "Desbloqueando…", - "Upload from Device": "Subir desde el dispositivo", "UPPERCASE": "MAYÚSCULAS", "Use": "Uso", "Use at least 8 characters.": "Utilice al menos 8 caracteres.", @@ -1027,24 +1020,44 @@ "Collapse": "Colapso", "converted to short links.": "convertido a enlaces cortos.", "Converting embedded images to short links.": "Conversión de imágenes incrustadas en enlaces cortos.", - "Drop Markdown files or images to add them.": "Suelta Markdown archivos o imágenes para agregarlos.", "Drop to import or insert": "Soltar para importar o insertar", "embedded image": "imagen incrustada", "Embedded images could not be converted. Please try again.": "Las imágenes incrustadas no se pudieron convertir. Por favor inténtalo de nuevo.", "expanded.": "ampliado.", - "for short links.": "para enlaces cortos.", "Image conversion was cancelled because the document changed.": "La conversión de imagen se canceló porque el documento cambió.", - "Image insertion was cancelled because the document changed.": "La inserción de la imagen se canceló porque el documento cambió.", - "Image upload cancelled.": "Carga de imagen cancelada.", - "Images larger than 25MB cannot be inserted.": "No se pueden insertar imágenes de más de 25 MB.", - "Markdown files and images are supported": "Se admiten archivos e imágenes Markdown", "MD": "MD", "Open an editable Markdown file before inserting images.": "Abra un archivo Markdown editable antes de insertar imágenes.", - "The image could not be inserted.": "No se pudo insertar la imagen.", "uploaded and inserted with short links.": "subido e insertado con enlaces cortos.", - "Uploading": "Subiendo", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Las imágenes del dispositivo se optimizan y se cargan para crear un enlace público breve que caduca después de 90 días. Cualquiera que tenga la URL de la imagen podrá verla hasta entonces.", "Mermaid diagram": "Diagrama de sirena", "Mermaid diagram actions": "Acciones del diagrama de sirena", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Para crear enlaces cortos, las imágenes se optimizan y se cargan en el almacenamiento público de imágenes Markdown Viewer por hasta 90 días. Cualquiera que tenga una URL de imagen puede verla. ¿Continuar?" + "\" aria-label=\"": "\"aria-label=\"", + "cancel": "cancelar", + "Description": "Descripción", + "Dismiss notification": "Descartar notificación", + "Drop Markdown files, images, GIFs, or videos to add them.": "Suelta Markdown archivos, imágenes, GIF o videos para agregarlos.", + "External Media (URL)": "Medios externos (URL)", + "Image, GIF, or video": "Imagen, GIF o vídeo", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Se cargan imágenes, GIF y videos para crear un enlace público corto que vence después de 90 días. Cualquiera que tenga la URL del medio podrá verlo hasta entonces. Límite de GIF: 5 MB; Límite de vídeo: 10 MB.", + "Insert image, GIF, or video": "Insertar imagen, GIF o vídeo", + "Markdown files, images, GIFs, and videos are supported": "Se admiten archivos Markdown, imágenes, GIF y vídeos", + "Media description": "Descripción multimedia", + "media file": "archivo multimedia", + "Media file": "Archivo multimedia", + "Media files larger than 25MB cannot be inserted.": "No se pueden insertar archivos multimedia de más de 25 MB.", + "Media insertion cancelled": "Inserción de medios cancelada", + "Media insertion was cancelled because the document changed.": "La inserción de medios se canceló porque el documento cambió.", + "Media upload cancelled.": "Carga de medios cancelada.", + "Media upload complete": "Carga de medios completada", + "Media upload failed": "Falló la carga de medios", + "Media upload progress": "Progreso de carga de medios", + "Media URL": "URL de medios", + "Notifications": "Notificaciones", + "Preparing upload...": "Preparando la carga...", + "The document changed during upload.": "El documento cambió durante la carga.", + "The media file could not be inserted.": "No se pudo insertar el archivo multimedia.", + "Upload Image, GIF, or Video": "Subir imagen, GIF o vídeo", + "Upload media?": "¿Subir medios?", + "Uploading media": "Subiendo medios", + "Uploading media file": "Subiendo archivo multimedia", + "Uploading media files": "Subiendo archivos multimedia" } diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index a7e450c9..b2e3fe30 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -63,7 +63,6 @@ "All changes saved": "Toutes les modifications enregistrées", "All workspace folders collapsed.": "Tous les dossiers de l'espace de travail ont été réduits.", "All workspace folders expanded.": "Tous les dossiers de l'espace de travail développés.", - "Alt Text (used for title)": "Texte alternatif (utilisé pour le titre)", "Apache License 2.0": "Licence Apache 2.0", "Appearance": "Apparence", "Application information": "Informations sur l'application", @@ -301,7 +300,6 @@ "Export document": "Exporter le document", "Export failed:": "Échec de l'exportation :", "Export PDF Options": "Options d'exportation PDF", - "External Image (URL)": "Image externe (URL)", "Faceted Gear Wheel": "Roue dentée à facettes", "Failed to copy HTML:": "Échec de la copie du code HTML :", "Failed to copy Markdown:": "Échec de la copie de Markdown :", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "entités HTML", "HTML export failed:": "Échec de l'exportation HTML :\nIdentifiant", - "Image": "Image", "Image (.png)": "Image (.png)", - "Image description": "Description de l'image", "Image generation progress": "Progression de la génération d'images", - "Image URL": "URL de l'image", "Implement live preview with GitHub styling": "Implémenter l'aperçu en direct avec le style GitHub", "Import": "Importer", "Import complete": "Importation terminée", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Blocs de code en ligne", "Insert": "Insérer", "Insert Diagram & More": "Insérer un diagramme et plus", - "Insert image": "Insérer une image", "Insert link": "Insérer un lien", "Insert reference": "Insérer une référence", "Insert table": "Insérer un tableau", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Déverrouiller l'espace de travail secret", "Unlock workspace": "Déverrouiller l'espace de travail", "Unlocking…": "Déverrouillage…", - "Upload from Device": "Télécharger depuis un appareil", "UPPERCASE": "MAJUSCULE", "Use": "Utiliser", "Use at least 8 characters.": "Utilisez au moins 8 caractères.", @@ -1027,24 +1020,44 @@ "Collapse": "Réduire", "converted to short links.": "converti en liens courts.", "Converting embedded images to short links.": "Conversion d'images intégrées en liens courts.", - "Drop Markdown files or images to add them.": "Déposez les fichiers ou images Markdown pour les ajouter.", "Drop to import or insert": "Déposer pour importer ou insérer", "embedded image": "image intégrée", "Embedded images could not be converted. Please try again.": "Les images intégrées n'ont pas pu être converties. Veuillez réessayer.", "expanded.": "développé.", - "for short links.": "pour les liens courts.", "Image conversion was cancelled because the document changed.": "La conversion de l'image a été annulée car le document a été modifié.", - "Image insertion was cancelled because the document changed.": "L'insertion de l'image a été annulée car le document a été modifié.", - "Image upload cancelled.": "Téléchargement de l'image annulé.", - "Images larger than 25MB cannot be inserted.": "Les images de plus de 25 Mo ne peuvent pas être insérées.", - "Markdown files and images are supported": "Les fichiers et images Markdown sont pris en charge", "MD": "MD", "Open an editable Markdown file before inserting images.": "Ouvrez un fichier Markdown modifiable avant d'insérer des images.", - "The image could not be inserted.": "L'image n'a pas pu être insérée.", "uploaded and inserted with short links.": "téléchargé et inséré avec des liens courts.", - "Uploading": "Téléchargement", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Les images des appareils sont optimisées et téléchargées pour créer un court lien public qui expire après 90 jours. D’ici là, toute personne disposant de l’URL de l’image peut la voir.", "Mermaid diagram": "Diagramme de la sirène", "Mermaid diagram actions": "Actions du diagramme de sirène", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Pour créer des liens courts, les images sont optimisées et téléchargées sur le stockage d'images public Markdown Viewer pendant 90 jours maximum. Toute personne disposant d’une URL d’image peut la voir. Continuer?" + "\" aria-label=\"": "\" aria-label=\"", + "cancel": "annuler", + "Description": "Description", + "Dismiss notification": "Rejeter la notification", + "Drop Markdown files, images, GIFs, or videos to add them.": "Déposez les fichiers, images, GIF ou vidéos Markdown pour les ajouter.", + "External Media (URL)": "Média externe (URL)", + "Image, GIF, or video": "Image, GIF ou vidéo", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Les images, GIF et vidéos sont téléchargés pour créer un court lien public qui expire après 90 jours. D’ici là, toute personne disposant de l’URL du média peut la consulter. Limite GIF : 5 Mo ; limite vidéo : 10 Mo.", + "Insert image, GIF, or video": "Insérer une image, un GIF ou une vidéo", + "Markdown files, images, GIFs, and videos are supported": "Les fichiers, images, GIF et vidéos Markdown sont pris en charge", + "Media description": "Description du média", + "media file": "Fichier multimédia", + "Media file": "Fichier multimédia", + "Media files larger than 25MB cannot be inserted.": "Les fichiers multimédias de plus de 25 Mo ne peuvent pas être insérés.", + "Media insertion cancelled": "Insertion du média annulée", + "Media insertion was cancelled because the document changed.": "L'insertion du support a été annulée car le document a été modifié.", + "Media upload cancelled.": "Téléchargement multimédia annulé.", + "Media upload complete": "Téléchargement du média terminé", + "Media upload failed": "Échec du téléchargement du média", + "Media upload progress": "Progression du téléchargement du média", + "Media URL": "URL du média", + "Notifications": "Notifications", + "Preparing upload...": "Préparation du téléchargement...", + "The document changed during upload.": "Le document a été modifié lors du téléchargement.", + "The media file could not be inserted.": "Le fichier multimédia n'a pas pu être inséré.", + "Upload Image, GIF, or Video": "Télécharger une image, un GIF ou une vidéo", + "Upload media?": "Télécharger des médias ?", + "Uploading media": "Téléchargement de média", + "Uploading media file": "Téléchargement d'un fichier multimédia", + "Uploading media files": "Téléchargement de fichiers multimédias" } diff --git a/assets/i18n/it.json b/assets/i18n/it.json index 456cda7b..a80b9c0f 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -63,7 +63,6 @@ "All changes saved": "Tutte le modifiche salvate", "All workspace folders collapsed.": "Tutte le cartelle dell'area di lavoro sono compresse.", "All workspace folders expanded.": "Tutte le cartelle dell'area di lavoro espanse.", - "Alt Text (used for title)": "Testo alternativo (utilizzato per il titolo)", "Apache License 2.0": "Licenza Apache 2.0", "Appearance": "Aspetto", "Application information": "Informazioni sull'applicazione", @@ -301,7 +300,6 @@ "Export document": "Esporta documento", "Export failed:": "Esportazione non riuscita:", "Export PDF Options": "Opzioni di esportazione PDF", - "External Image (URL)": "Immagine esterna (URL)", "Faceted Gear Wheel": "Ruota dentata sfaccettata", "Failed to copy HTML:": "Impossibile copiare l'HTML:", "Failed to copy Markdown:": "Impossibile copiare Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "Entità HTML", "HTML export failed:": "Esportazione HTML non riuscita:", - "Image": "Immagine", "Image (.png)": "Immagine (.png)", - "Image description": "Descrizione dell'immagine", "Image generation progress": "Avanzamento della generazione dell'immagine", - "Image URL": "URL dell'immagine", "Implement live preview with GitHub styling": "Implementa l'anteprima live con lo stile GitHub", "Import": "Importa", "Import complete": "Importazione completata", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Blocchi di codice in linea", "Insert": "Inserisci", "Insert Diagram & More": "Inserisci diagramma e altro", - "Insert image": "Inserisci immagine", "Insert link": "Inserisci collegamento", "Insert reference": "Inserisci riferimento", "Insert table": "Inserisci tabella", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Sblocca l'area di lavoro segreta", "Unlock workspace": "Sblocca l'area di lavoro", "Unlocking…": "Sblocco…", - "Upload from Device": "Carica dal dispositivo", "UPPERCASE": "MAIUSCOLO", "Use": "Utilizzare", "Use at least 8 characters.": "Utilizzare almeno 8 caratteri.", @@ -1027,24 +1020,44 @@ "Collapse": "Comprimi", "converted to short links.": "convertito in collegamenti brevi.", "Converting embedded images to short links.": "Conversione di immagini incorporate in collegamenti brevi.", - "Drop Markdown files or images to add them.": "Rilascia Markdown file o immagini per aggiungerli.", "Drop to import or insert": "Rilascia per importare o inserire", "embedded image": "immagine incorporata", "Embedded images could not be converted. Please try again.": "Impossibile convertire le immagini incorporate. Per favore riprova.", "expanded.": "ampliato.", - "for short links.": "per i collegamenti brevi.", "Image conversion was cancelled because the document changed.": "La conversione dell'immagine è stata annullata perché il documento è cambiato.", - "Image insertion was cancelled because the document changed.": "L'inserimento dell'immagine è stato annullato perché il documento è cambiato.", - "Image upload cancelled.": "Caricamento immagine annullato.", - "Images larger than 25MB cannot be inserted.": "Non è possibile inserire immagini di dimensioni superiori a 25 MB.", - "Markdown files and images are supported": "Sono supportati file e immagini Markdown", "MD": "MD", "Open an editable Markdown file before inserting images.": "Apri un file modificabile Markdown prima di inserire le immagini.", - "The image could not be inserted.": "Impossibile inserire l'immagine.", "uploaded and inserted with short links.": "caricato e inserito con collegamenti brevi.", - "Uploading": "Caricamento", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Le immagini del dispositivo vengono ottimizzate e caricate per creare un breve collegamento pubblico che scade dopo 90 giorni. Chiunque abbia l'URL dell'immagine potrà visualizzarla fino ad allora.", "Mermaid diagram": "Schema della sirena", "Mermaid diagram actions": "Azioni del diagramma della sirena", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Per creare collegamenti brevi, le immagini vengono ottimizzate e caricate nell'archivio pubblico di immagini Markdown Viewer per un massimo di 90 giorni. Chiunque abbia un URL di immagine può visualizzarlo. Continuare?" + "\" aria-label=\"": "\" aria-etichetta=\"", + "cancel": "annulla", + "Description": "Descrizione", + "Dismiss notification": "Ignora la notifica", + "Drop Markdown files, images, GIFs, or videos to add them.": "Rilascia Markdown file, immagini, GIF o video per aggiungerli.", + "External Media (URL)": "Supporti esterni (URL)", + "Image, GIF, or video": "Immagine, GIF o video", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Immagini, GIF e video vengono caricati per creare un breve collegamento pubblico che scade dopo 90 giorni. Chiunque abbia l'URL del supporto può visualizzarlo fino ad allora. Limite GIF: 5 MB; limite video: 10 MB.", + "Insert image, GIF, or video": "Inserisci immagine, GIF o video", + "Markdown files, images, GIFs, and videos are supported": "Markdown sono supportati file, immagini, GIF e video", + "Media description": "Descrizione del supporto", + "media file": "file multimediale", + "Media file": "File multimediale", + "Media files larger than 25MB cannot be inserted.": "Non è possibile inserire file multimediali di dimensioni superiori a 25 MB.", + "Media insertion cancelled": "Inserimento del supporto annullato", + "Media insertion was cancelled because the document changed.": "L'inserimento del supporto è stato annullato perché il documento è cambiato.", + "Media upload cancelled.": "Caricamento multimediale annullato.", + "Media upload complete": "Caricamento multimediale completato", + "Media upload failed": "Caricamento multimediale non riuscito", + "Media upload progress": "Avanzamento del caricamento multimediale", + "Media URL": "URL del supporto", + "Notifications": "Notifiche", + "Preparing upload...": "Preparazione caricamento...", + "The document changed during upload.": "Il documento è cambiato durante il caricamento.", + "The media file could not be inserted.": "Impossibile inserire il file multimediale.", + "Upload Image, GIF, or Video": "Carica immagine, GIF o video", + "Upload media?": "Caricare contenuti multimediali?", + "Uploading media": "Caricamento dei contenuti multimediali", + "Uploading media file": "Caricamento del file multimediale", + "Uploading media files": "Caricamento di file multimediali" } diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index f4e27b12..52f97330 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -63,7 +63,6 @@ "All changes saved": "すべての変更が保存されました", "All workspace folders collapsed.": "すべてのワークスペース フォルダーが折りたたまれました。", "All workspace folders expanded.": "すべてのワークスペース フォルダーが展開されました。", - "Alt Text (used for title)": "代替テキスト (タイトルに使用)", "Apache License 2.0": "Apache ライセンス 2.0", "Appearance": "Appearance", "Application information": "申請情報", @@ -301,7 +300,6 @@ "Export document": "ドキュメントのエクスポート", "Export failed:": "エクスポートに失敗しました:", "Export PDF Options": "PDF エクスポート オプション", - "External Image (URL)": "外部画像(URL)", "Faceted Gear Wheel": "多面歯車", "Failed to copy HTML:": "HTML のコピーに失敗しました:", "Failed to copy Markdown:": "Markdown のコピーに失敗しました:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML エンティティ", "HTML export failed:": "HTML エクスポートに失敗しました:", - "Image": "画像", "Image (.png)": "画像 (.png)\n【[MV11]】 画像の説明", - "Image description": "Image description", "Image generation progress": "画像生成の進捗状況", - "Image URL": "画像URL", "Implement live preview with GitHub styling": "GitHub スタイルを使用してライブ プレビューを実装する", "Import": "インポート", "Import complete": "インポート完了", @@ -433,7 +428,6 @@ "Inline Code Blocks": "インライン コード ブロック", "Insert": "インサート", "Insert Diagram & More": "図などを挿入", - "Insert image": "画像の挿入", "Insert link": "リンクを挿入", "Insert reference": "参照の挿入", "Insert table": "テーブルの挿入", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "秘密のワークスペースのロックを解除する", "Unlock workspace": "ワークスペースのロックを解除する", "Unlocking…": "ロック解除中…", - "Upload from Device": "デバイスからアップロード", "UPPERCASE": "大文字", "Use": "使用", "Use at least 8 characters.": "8 文字以上を使用してください。", @@ -1027,24 +1020,44 @@ "Collapse": "崩壊", "converted to short links.": "は短いリンクに変換されました。", "Converting embedded images to short links.": "埋め込まれた画像を短いリンクに変換します。", - "Drop Markdown files or images to add them.": "Markdown ファイルまたは画像をドロップして追加します。", "Drop to import or insert": "ドロップしてインポートまたは挿入します", "embedded image": "埋め込み画像", "Embedded images could not be converted. Please try again.": "埋め込み画像を変換できませんでした。もう一度試してください。", "expanded.": "を展開しました。", - "for short links.": "短いリンク。", "Image conversion was cancelled because the document changed.": "ドキュメントが変更されたため、画像変換がキャンセルされました。", - "Image insertion was cancelled because the document changed.": "ドキュメントが変更されたため、画像の挿入はキャンセルされました。", - "Image upload cancelled.": "画像のアップロードがキャンセルされました。\n[[MV13]] 25MBを超える画像は挿入できません。", - "Images larger than 25MB cannot be inserted.": "Images larger than 25MB cannot be inserted.", - "Markdown files and images are supported": "Markdown ファイルと画像がサポートされています", "MD": "MD", "Open an editable Markdown file before inserting images.": "画像を挿入する前に、編集可能な Markdown ファイルを開いてください。", - "The image could not be inserted.": "画像を挿入できませんでした。", "uploaded and inserted with short links.": "がアップロードされ、短いリンクが挿入されました。", - "Uploading": "アップロード中", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "デバイス イメージは最適化され、90 日後に期限切れになる短いパブリック リンクを作成するようにアップロードされます。それまでは、画像の URL を知っている人は誰でも閲覧できます。", "Mermaid diagram": "マーメイドダイアグラム", "Mermaid diagram actions": "マーメイドダイアグラムのアクション", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "短いリンクを作成するために、画像は最適化され、Markdown Viewer パブリック画像ストレージに最大 90 日間アップロードされます。画像の URL を知っている人は誰でも閲覧できます。続く?" + "\" aria-label=\"": "\" aria-label=\"", + "cancel": "キャンセル", + "Description": "説明", + "Dismiss notification": "通知を閉じる", + "Drop Markdown files, images, GIFs, or videos to add them.": "Markdown ファイル、画像、GIF、またはビデオをドロップして追加します。", + "External Media (URL)": "外部メディア(URL)", + "Image, GIF, or video": "画像、GIF、またはビデオ", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "画像、GIF、ビデオをアップロードして、90 日後に有効期限が切れる短いパブリック リンクを作成します。それまでは、メディアの URL を知っている人は誰でも視聴できます。 GIF の制限: 5 MB;ビデオ制限: 10 MB。", + "Insert image, GIF, or video": "画像、GIF、またはビデオを挿入します", + "Markdown files, images, GIFs, and videos are supported": "Markdown ファイル、画像、GIF、ビデオがサポートされています", + "Media description": "メディア説明", + "media file": "メディア ファイル", + "Media file": "メディア ファイル", + "Media files larger than 25MB cannot be inserted.": "25MBを超えるメディアファイルは挿入できません。", + "Media insertion cancelled": "メディアの挿入がキャンセルされました", + "Media insertion was cancelled because the document changed.": "ドキュメントが変更されたため、メディアの挿入がキャンセルされました。", + "Media upload cancelled.": "メディアのアップロードがキャンセルされました。", + "Media upload complete": "メディアのアップロードが完了しました", + "Media upload failed": "メディアのアップロードに失敗しました", + "Media upload progress": "メディアのアップロードの進行状況", + "Media URL": "メディア URL", + "Notifications": "お知らせ", + "Preparing upload...": "アップロードを準備しています...", + "The document changed during upload.": "アップロード中にドキュメントが変更されました。", + "The media file could not be inserted.": "メディア ファイルを挿入できませんでした。", + "Upload Image, GIF, or Video": "画像、GIF、またはビデオをアップロードする", + "Upload media?": "メディアをアップロードしますか?", + "Uploading media": "メディアのアップロード", + "Uploading media file": "メディア ファイルのアップロード", + "Uploading media files": "メディア ファイルのアップロード" } diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index e8f9f0f6..849ddff1 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -63,7 +63,6 @@ "All changes saved": "모든 변경 사항이 저장되었습니다.", "All workspace folders collapsed.": "모든 작업공간 폴더가 축소되었습니다.", "All workspace folders expanded.": "모든 작업 공간 폴더가 확장되었습니다.", - "Alt Text (used for title)": "대체 텍스트(제목에 사용됨)", "Apache License 2.0": "아파치 라이선스 2.0", "Appearance": "외관", "Application information": "응모안내", @@ -301,7 +300,6 @@ "Export document": "문서 내보내기", "Export failed:": "내보내기 실패:", "Export PDF Options": "PDF 내보내기 옵션", - "External Image (URL)": "외부 이미지(URL)", "Faceted Gear Wheel": "면처리된 기어 휠", "Failed to copy HTML:": "HTML을 복사하지 못했습니다.", "Failed to copy Markdown:": "Markdown을(를) 복사하지 못했습니다.", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML 엔터티", "HTML export failed:": "HTML 내보내기 실패:", - "Image": "이미지", "Image (.png)": "이미지(.png)", - "Image description": "이미지 설명", "Image generation progress": "이미지 생성 진행", - "Image URL": "이미지 URL", "Implement live preview with GitHub styling": "GitHub 스타일링으로 실시간 미리보기 구현", "Import": "가져오기", "Import complete": "가져오기 완료", @@ -433,7 +428,6 @@ "Inline Code Blocks": "인라인 코드 블록", "Insert": "삽입", "Insert Diagram & More": "다이어그램 삽입 및 기타", - "Insert image": "이미지 삽입", "Insert link": "링크 삽입", "Insert reference": "참고자료 삽입", "Insert table": "테이블 삽입", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "비밀 작업 공간 잠금 해제", "Unlock workspace": "작업공간 잠금해제", "Unlocking…": "잠금해제 중…", - "Upload from Device": "기기에서 업로드", "UPPERCASE": "대문자", "Use": "사용", "Use at least 8 characters.": "8자 이상을 사용하세요.", @@ -1027,24 +1020,44 @@ "Collapse": "접기", "converted to short links.": "이 짧은 링크로 변환되었습니다.", "Converting embedded images to short links.": "삽입된 이미지를 짧은 링크로 변환하는 중입니다.", - "Drop Markdown files or images to add them.": "Markdown 파일이나 이미지를 드롭하여 추가하세요.", "Drop to import or insert": "가져오기 또는 삽입하려면 드롭하세요.", "embedded image": "삽입된 이미지", "Embedded images could not be converted. Please try again.": "삽입된 이미지를 변환할 수 없습니다. 다시 시도해 주세요.", "expanded.": "이 확장되었습니다.", - "for short links.": "짧은 링크용.", "Image conversion was cancelled because the document changed.": "문서가 변경되어 이미지 변환이 취소되었습니다.", - "Image insertion was cancelled because the document changed.": "문서가 변경되어 이미지 삽입이 취소되었습니다.", - "Image upload cancelled.": "이미지 업로드가 취소되었습니다.", - "Images larger than 25MB cannot be inserted.": "25MB보다 큰 이미지는 삽입할 수 없습니다.", - "Markdown files and images are supported": "Markdown 파일 및 이미지가 지원됩니다.", "MD": "MD", "Open an editable Markdown file before inserting images.": "이미지를 삽입하기 전에 편집 가능한 Markdown 파일을 엽니다.", - "The image could not be inserted.": "이미지를 삽입할 수 없습니다.", "uploaded and inserted with short links.": "짧은 링크와 함께 업로드 및 삽입되었습니다.", - "Uploading": "업로드 중", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "장치 이미지는 90일 후에 만료되는 짧은 공개 링크를 생성하기 위해 최적화 및 업로드됩니다. 그 전까지는 이미지 URL이 있는 사람은 누구나 볼 수 있습니다.", "Mermaid diagram": "인어 다이어그램", "Mermaid diagram actions": "인어 다이어그램 액션", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "짧은 링크를 만들기 위해 이미지가 최적화되어 최대 90일 동안 Markdown Viewer 공개 이미지 저장소에 업로드됩니다. 이미지 URL이 있는 사람은 누구나 볼 수 있습니다. 계속하다?" + "\" aria-label=\"": "\" 아리아-라벨=\"", + "cancel": "취소", + "Description": "설명", + "Dismiss notification": "알림 닫기", + "Drop Markdown files, images, GIFs, or videos to add them.": "Markdown 파일, 이미지, GIF 또는 비디오를 드롭하여 추가하세요.", + "External Media (URL)": "외부미디어(URL)", + "Image, GIF, or video": "이미지, GIF, 동영상", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "이미지, GIF, 동영상을 업로드하여 90일 후에 만료되는 짧은 공개 링크를 만듭니다. 그때까지는 미디어 URL이 있는 사람은 누구나 해당 내용을 볼 수 있습니다. GIF 제한: 5MB; 비디오 제한: 10MB.", + "Insert image, GIF, or video": "이미지, GIF, 동영상 삽입", + "Markdown files, images, GIFs, and videos are supported": "Markdown 파일, 이미지, GIF, 동영상이 지원됩니다.", + "Media description": "미디어 설명", + "media file": "미디어 파일", + "Media file": "미디어 파일", + "Media files larger than 25MB cannot be inserted.": "25MB보다 큰 미디어 파일은 삽입할 수 없습니다.", + "Media insertion cancelled": "미디어 삽입이 취소되었습니다.", + "Media insertion was cancelled because the document changed.": "문서가 변경되어 미디어 삽입이 취소되었습니다.", + "Media upload cancelled.": "미디어 업로드가 취소되었습니다.", + "Media upload complete": "미디어 업로드 완료", + "Media upload failed": "미디어 업로드 실패", + "Media upload progress": "미디어 업로드 진행", + "Media URL": "미디어 URL", + "Notifications": "알림", + "Preparing upload...": "업로드 준비 중...", + "The document changed during upload.": "업로드 중에 문서가 변경되었습니다.", + "The media file could not be inserted.": "미디어 파일을 삽입할 수 없습니다.", + "Upload Image, GIF, or Video": "이미지, GIF, 동영상 업로드", + "Upload media?": "미디어를 업로드하시겠습니까?", + "Uploading media": "미디어 업로드 중", + "Uploading media file": "미디어 파일 업로드 중", + "Uploading media files": "미디어 파일 업로드 중" } diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index 95da0e4f..15647fbf 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -63,7 +63,6 @@ "All changes saved": "Wszystkie zmiany zapisane", "All workspace folders collapsed.": "Wszystkie foldery obszaru roboczego zostały zwinięte.", "All workspace folders expanded.": "Wszystkie foldery obszaru roboczego rozwinięte.", - "Alt Text (used for title)": "Tekst alternatywny (używany w tytule)", "Apache License 2.0": "Licencja Apache 2.0", "Appearance": "Wygląd", "Application information": "Informacje o aplikacji", @@ -301,7 +300,6 @@ "Export document": "Eksportuj dokument", "Export failed:": "Eksport nie powiódł się:", "Export PDF Options": "Opcje eksportu PDF", - "External Image (URL)": "Obraz zewnętrzny (URL)", "Faceted Gear Wheel": "Fasetowane koło zębate", "Failed to copy HTML:": "Nie udało się skopiować kodu HTML:", "Failed to copy Markdown:": "Nie udało się skopiować Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "Elementy HTML", "HTML export failed:": "Eksport HTML nie powiódł się:", - "Image": "Obraz", "Image (.png)": "Obraz (.png)", - "Image description": "Opis obrazu", "Image generation progress": "Postęp generowania obrazu", - "Image URL": "Adres URL obrazu", "Implement live preview with GitHub styling": "Zaimplementuj podgląd na żywo ze stylizacją GitHub", "Import": "Importuj", "Import complete": "Import zakończony", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Bloki kodu wbudowanego", "Insert": "Wstaw", "Insert Diagram & More": "Wstaw diagram i więcej", - "Insert image": "Wstaw obraz", "Insert link": "Wstaw link", "Insert reference": "Wstaw odniesienie", "Insert table": "Wstaw tabelę", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Odblokuj tajną przestrzeń roboczą", "Unlock workspace": "Odblokuj przestrzeń roboczą", "Unlocking…": "Odblokowywanie…", - "Upload from Device": "Prześlij z urządzenia", "UPPERCASE": "WIELKIE LITERY", "Use": "Użyj", "Use at least 8 characters.": "Użyj co najmniej 8 znaków.", @@ -1027,24 +1020,44 @@ "Collapse": "Zwiń", "converted to short links.": "przekonwertowane na krótkie linki.", "Converting embedded images to short links.": "Konwersja osadzonych obrazów na krótkie linki.", - "Drop Markdown files or images to add them.": "Usuń Markdown plików lub obrazów, aby je dodać.", "Drop to import or insert": "Upuść, aby zaimportować lub wstawić", "embedded image": "osadzony obraz", "Embedded images could not be converted. Please try again.": "Nie można przekonwertować osadzonych obrazów. Spróbuj ponownie.", "expanded.": "rozwinięte.", - "for short links.": "dla krótkich linków.", "Image conversion was cancelled because the document changed.": "Konwersja obrazu została anulowana z powodu zmiany dokumentu.", - "Image insertion was cancelled because the document changed.": "Wstawianie obrazu zostało anulowane, ponieważ dokument się zmienił.", - "Image upload cancelled.": "Przesyłanie obrazu zostało anulowane.", - "Images larger than 25MB cannot be inserted.": "Nie można wstawiać obrazów większych niż 25 MB.", - "Markdown files and images are supported": "Markdown obsługiwane są pliki i obrazy", "MD": "Lek", "Open an editable Markdown file before inserting images.": "Otwórz edytowalny plik Markdown przed wstawieniem obrazów.", - "The image could not be inserted.": "Nie można wstawić obrazu.", "uploaded and inserted with short links.": "przesłano i wstawiono z krótkimi linkami.", - "Uploading": "Przesyłanie", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Obrazy urządzeń są optymalizowane i przesyłane w celu utworzenia krótkiego łącza publicznego, które wygasa po 90 dniach. Do tego czasu każdy, kto zna adres URL obrazu, będzie mógł go wyświetlić.", "Mermaid diagram": "Schemat syreny", "Mermaid diagram actions": "Działania na diagramie syreny", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Aby utworzyć krótkie linki, obrazy są optymalizowane i przesyłane do publicznego magazynu obrazów Markdown Viewer na okres do 90 dni. Każdy, kto ma adres URL obrazu, może go wyświetlić. Kontynuować?" + "\" aria-label=\"": "\" aria-label=\"", + "cancel": "anuluj", + "Description": "Opis", + "Dismiss notification": "Odrzuć powiadomienie", + "Drop Markdown files, images, GIFs, or videos to add them.": "Upuść Markdown plików, obrazów, GIF-ów lub filmów, aby je dodać.", + "External Media (URL)": "Media zewnętrzne (URL)", + "Image, GIF, or video": "Obraz, GIF lub wideo", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Obrazy, pliki GIF i filmy są przesyłane w celu utworzenia krótkiego linku publicznego, który wygasa po 90 dniach. Do tego czasu każdy, kto ma adres URL multimediów, będzie mógł je wyświetlić. Limit GIF: 5 MB; limit wideo: 10 MB.", + "Insert image, GIF, or video": "Wstaw obraz, GIF lub wideo", + "Markdown files, images, GIFs, and videos are supported": "Markdown obsługiwane są pliki, obrazy, pliki GIF i filmy", + "Media description": "Opis multimediów", + "media file": "plik multimedialny", + "Media file": "Plik multimedialny", + "Media files larger than 25MB cannot be inserted.": "Nie można wstawiać plików multimedialnych większych niż 25MB.", + "Media insertion cancelled": "Wstawianie multimediów zostało anulowane", + "Media insertion was cancelled because the document changed.": "Wstawianie multimediów zostało anulowane, ponieważ dokument się zmienił.", + "Media upload cancelled.": "Przesyłanie multimediów zostało anulowane.", + "Media upload complete": "Przesyłanie multimediów zostało zakończone", + "Media upload failed": "Przesyłanie multimediów nie powiodło się", + "Media upload progress": "Postęp przesyłania multimediów", + "Media URL": "Adres URL multimediów", + "Notifications": "Powiadomienia", + "Preparing upload...": "Przygotowuję przesyłanie...", + "The document changed during upload.": "Dokument zmienił się podczas przesyłania.", + "The media file could not be inserted.": "Nie można wstawić pliku multimedialnego.", + "Upload Image, GIF, or Video": "Prześlij obraz, GIF lub wideo", + "Upload media?": "Przesłać multimedia?", + "Uploading media": "Przesyłanie multimediów", + "Uploading media file": "Przesyłanie pliku multimedialnego", + "Uploading media files": "Przesyłanie plików multimedialnych" } diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 08d24bdb..f700436c 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -63,7 +63,6 @@ "All changes saved": "Todas as alterações salvas", "All workspace folders collapsed.": "Todas as pastas do espaço de trabalho foram recolhidas.", "All workspace folders expanded.": "Todas as pastas do espaço de trabalho expandidas.", - "Alt Text (used for title)": "Alt Text (usado como título)", "Apache License 2.0": "Licença Apache 2.0", "Appearance": "Aparência", "Application information": "Informações sobre inscrição", @@ -301,7 +300,6 @@ "Export document": "Exportar documento", "Export failed:": "Falha na exportação:", "Export PDF Options": "Opções de exportação de PDF", - "External Image (URL)": "Imagem externa (URL)", "Faceted Gear Wheel": "Roda dentada facetada", "Failed to copy HTML:": "Falha ao copiar HTML:", "Failed to copy Markdown:": "Falha ao copiar Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "Entidades HTML", "HTML export failed:": "Falha na exportação de HTML:", - "Image": "Imagem", "Image (.png)": "Imagem (.png)", - "Image description": "Descrição da imagem", "Image generation progress": "Progresso da geração de imagens", - "Image URL": "URL da imagem", "Implement live preview with GitHub styling": "Implementar visualização ao vivo com estilo GitHub", "Import": "Importar", "Import complete": "Importação concluída", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Blocos de código embutidos", "Insert": "Inserir", "Insert Diagram & More": "Inserir diagrama e mais", - "Insert image": "Inserir imagem", "Insert link": "Inserir link", "Insert reference": "Inserir referência", "Insert table": "Inserir tabela", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Desbloquear espaço de trabalho secreto", "Unlock workspace": "Desbloquear espaço de trabalho", "Unlocking…": "Desbloqueando…", - "Upload from Device": "Carregar do dispositivo", "UPPERCASE": "MAIÚSCULAS", "Use": "Usar", "Use at least 8 characters.": "Use pelo menos 8 caracteres.", @@ -1027,24 +1020,44 @@ "Collapse": "Recolher", "converted to short links.": "convertido em links curtos.", "Converting embedded images to short links.": "Convertendo imagens incorporadas em links curtos.", - "Drop Markdown files or images to add them.": "Solte Markdown arquivos ou imagens para adicioná-los.", "Drop to import or insert": "Solte para importar ou inserir", "embedded image": "imagem incorporada", "Embedded images could not be converted. Please try again.": "Não foi possível converter imagens incorporadas. Por favor, tente novamente.", "expanded.": "expandido.", - "for short links.": "para links curtos.", "Image conversion was cancelled because the document changed.": "A conversão da imagem foi cancelada porque o documento foi alterado.", - "Image insertion was cancelled because the document changed.": "A inserção da imagem foi cancelada porque o documento foi alterado.", - "Image upload cancelled.": "Upload de imagem cancelado.", - "Images larger than 25MB cannot be inserted.": "Imagens maiores que 25 MB não podem ser inseridas.", - "Markdown files and images are supported": "Markdown arquivos e imagens são suportados", "MD": "Médico", "Open an editable Markdown file before inserting images.": "Abra um arquivo Markdown editável antes de inserir imagens.", - "The image could not be inserted.": "Não foi possível inserir a imagem.", "uploaded and inserted with short links.": "carregado e inserido com links curtos.", - "Uploading": "Enviando", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "As imagens do dispositivo são otimizadas e carregadas para criar um link público curto que expira após 90 dias. Qualquer pessoa com o URL da imagem poderá visualizá-la até então.", "Mermaid diagram": "Diagrama de sereia", "Mermaid diagram actions": "Ações do diagrama sereia", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Para criar links curtos, as imagens são otimizadas e enviadas para o armazenamento público de imagens Markdown Viewer por até 90 dias. Qualquer pessoa com um URL de imagem pode visualizá-la. Continuar?" + "\" aria-label=\"": "\"aria-label=\"", + "cancel": "cancelar", + "Description": "Descrição", + "Dismiss notification": "Dispensar notificação", + "Drop Markdown files, images, GIFs, or videos to add them.": "Solte Markdown arquivos, imagens, GIFs ou vídeos para adicioná-los.", + "External Media (URL)": "Mídia Externa (URL)", + "Image, GIF, or video": "Imagem, GIF ou vídeo", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Imagens, GIFs e vídeos são enviados para criar um link público curto que expira após 90 dias. Qualquer pessoa com o URL da mídia poderá visualizá-lo até então. Limite de GIFs: 5 MB; limite de vídeo: 10 MB.", + "Insert image, GIF, or video": "Inserir imagem, GIF ou vídeo", + "Markdown files, images, GIFs, and videos are supported": "Markdown arquivos, imagens, GIFs e vídeos são suportados", + "Media description": "Descrição da mídia", + "media file": "arquivo de mídia", + "Media file": "Arquivo de mídia", + "Media files larger than 25MB cannot be inserted.": "Arquivos de mídia maiores que 25 MB não podem ser inseridos.", + "Media insertion cancelled": "Inserção de mídia cancelada", + "Media insertion was cancelled because the document changed.": "A inserção da mídia foi cancelada porque o documento foi alterado.", + "Media upload cancelled.": "Upload de mídia cancelado.", + "Media upload complete": "Upload de mídia concluído", + "Media upload failed": "Falha no upload de mídia", + "Media upload progress": "Progresso do upload de mídia", + "Media URL": "URL de mídia", + "Notifications": "Notificações", + "Preparing upload...": "Preparando upload...", + "The document changed during upload.": "O documento foi alterado durante o upload.", + "The media file could not be inserted.": "O arquivo de mídia não pôde ser inserido.", + "Upload Image, GIF, or Video": "Enviar imagem, GIF ou vídeo", + "Upload media?": "Carregar mídia?", + "Uploading media": "Fazendo upload de mídia", + "Uploading media file": "Fazendo upload do arquivo de mídia", + "Uploading media files": "Fazendo upload de arquivos de mídia" } diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index 006eca86..3b323075 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -63,7 +63,6 @@ "All changes saved": "Все изменения сохранены.", "All workspace folders collapsed.": "Все папки рабочей области свернуты.", "All workspace folders expanded.": "Все папки рабочего пространства развернуты.", - "Alt Text (used for title)": "Альтернативный текст (используется для заголовка)", "Apache License 2.0": "Лицензия Apache 2.0", "Appearance": "Внешний вид", "Application information": "Информация о приложении", @@ -301,7 +300,6 @@ "Export document": "Экспортный документ", "Export failed:": "Не удалось выполнить экспорт:", "Export PDF Options": "Параметры экспорта PDF", - "External Image (URL)": "Внешнее изображение (URL)", "Faceted Gear Wheel": "Грановитая шестерня", "Failed to copy HTML:": "Не удалось скопировать HTML:", "Failed to copy Markdown:": "Не удалось скопировать Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML-объекты", "HTML export failed:": "Не удалось экспортировать HTML:\nИдентификатор", - "Image": "Изображение", "Image (.png)": "Изображение (.png)", - "Image description": "Описание изображения", "Image generation progress": "Ход создания изображения", - "Image URL": "URL изображения", "Implement live preview with GitHub styling": "Реализовать предварительный просмотр в реальном времени со стилем GitHub.", "Import": "Импорт", "Import complete": "Импорт завершен", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Встроенные блоки кода", "Insert": "Вставка", "Insert Diagram & More": "Вставка диаграммы и многое другое", - "Insert image": "Вставить изображение", "Insert link": "Вставить ссылку", "Insert reference": "Вставить ссылку", "Insert table": "Вставить таблицу", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Разблокировать секретное рабочее пространство", "Unlock workspace": "Разблокировать рабочее пространство", "Unlocking…": "Разблокировка…", - "Upload from Device": "Загрузка с устройства", "UPPERCASE": "ПРОПИСНЫЕ РЕГИСТРЫ", "Use": "Использование", "Use at least 8 characters.": "Используйте не менее 8 символов.", @@ -1027,24 +1020,44 @@ "Collapse": "Свернуть", "converted to short links.": "преобразовано в короткие ссылки.", "Converting embedded images to short links.": "Преобразование встроенных изображений в короткие ссылки.", - "Drop Markdown files or images to add them.": "Перетащите Markdown файлы или изображения, чтобы добавить их.", "Drop to import or insert": "Отбросьте, чтобы импортировать или вставить", "embedded image": "встроенное изображение", "Embedded images could not be converted. Please try again.": "Не удалось преобразовать встроенные изображения. Пожалуйста, попробуйте еще раз.", "expanded.": "расширено.", - "for short links.": "для коротких ссылок.", "Image conversion was cancelled because the document changed.": "Преобразование изображения было отменено, поскольку документ изменился.", - "Image insertion was cancelled because the document changed.": "Вставка изображения была отменена, поскольку документ изменился.", - "Image upload cancelled.": "Загрузка изображения отменена.", - "Images larger than 25MB cannot be inserted.": "Невозможно вставить изображения размером более 25 МБ.", - "Markdown files and images are supported": "Поддерживаются файлы и изображения Markdown", "MD": "Доктор медицины", "Open an editable Markdown file before inserting images.": "Прежде чем вставлять изображения, откройте редактируемый файл Markdown.", - "The image could not be inserted.": "Не удалось вставить изображение.", "uploaded and inserted with short links.": "загружено и вставлено с короткими ссылками.", - "Uploading": "Загрузка", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Образы устройств оптимизируются и загружаются для создания короткой общедоступной ссылки, срок действия которой истекает через 90 дней. До этого момента любой, у кого есть URL-адрес изображения, сможет его просмотреть.", "Mermaid diagram": "Схема русалки", "Mermaid diagram actions": "Действия на диаграмме русалки", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Для создания коротких ссылок изображения оптимизируются и загружаются в общедоступное хранилище изображений Markdown Viewer на срок до 90 дней. Любой, у кого есть URL-адрес изображения, может просмотреть его. Продолжать?" + "\" aria-label=\"": "\" aria-label=\"", + "cancel": "отменить", + "Description": "Описание", + "Dismiss notification": "Закрыть уведомление", + "Drop Markdown files, images, GIFs, or videos to add them.": "Перетащите Markdown файлы, изображения, GIF-файлы или видео, чтобы добавить их.", + "External Media (URL)": "Внешний носитель (URL)", + "Image, GIF, or video": "Изображение, GIF или видео", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Изображения, GIF-файлы и видео загружаются для создания короткой общедоступной ссылки, срок действия которой истекает через 90 дней. До этого момента любой, у кого есть URL-адрес мультимедиа, сможет его просмотреть. Ограничение GIF: 5 МБ; лимит видео: 10 МБ.", + "Insert image, GIF, or video": "Вставьте изображение, GIF или видео.", + "Markdown files, images, GIFs, and videos are supported": "Поддерживаются файлы Markdown, изображения, GIF-файлы и видео.", + "Media description": "Описание мультимедиа", + "media file": "медиа-файл", + "Media file": "Медиа-файл", + "Media files larger than 25MB cannot be inserted.": "Невозможно вставить медиа-файлы размером более 25 МБ.", + "Media insertion cancelled": "Вставка носителя отменена", + "Media insertion was cancelled because the document changed.": "Вставка носителя была отменена, поскольку документ изменился.", + "Media upload cancelled.": "Загрузка мультимедиа отменена.", + "Media upload complete": "Загрузка мультимедиа завершена", + "Media upload failed": "Не удалось загрузить медиафайл", + "Media upload progress": "Ход загрузки мультимедиа", + "Media URL": "URL-адрес мультимедиа", + "Notifications": "Уведомления", + "Preparing upload...": "Подготовка к загрузке...", + "The document changed during upload.": "Документ изменился во время загрузки.", + "The media file could not be inserted.": "Не удалось вставить медиафайл.", + "Upload Image, GIF, or Video": "Загрузите изображение, GIF или видео", + "Upload media?": "Загрузить медиафайл?", + "Uploading media": "Загрузка медиафайлов", + "Uploading media file": "Загрузка медиафайла", + "Uploading media files": "Загрузка медиафайлов" } diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index dc20e13c..580c067c 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -63,7 +63,6 @@ "All changes saved": "Tüm değişiklikler kaydedildi", "All workspace folders collapsed.": "Tüm çalışma alanı klasörleri daraltıldı.", "All workspace folders expanded.": "Tüm çalışma alanı klasörleri genişletildi.", - "Alt Text (used for title)": "Alternatif Metin (başlık için kullanılır)", "Apache License 2.0": "Apache Lisansı 2.0", "Appearance": "Görünüm", "Application information": "Uygulama bilgisi", @@ -301,7 +300,6 @@ "Export document": "Belgeyi dışa aktar", "Export failed:": "Dışa aktarma başarısız oldu:", "Export PDF Options": "PDF'yi Dışa Aktarma Seçenekleri", - "External Image (URL)": "Harici Görüntü (URL)", "Faceted Gear Wheel": "Yönlü Dişli Çark", "Failed to copy HTML:": "HTML kopyalanamadı:", "Failed to copy Markdown:": "Markdown kopyalanamadı:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML varlıkları", "HTML export failed:": "HTML dışa aktarımı başarısız oldu:", - "Image": "Görüntü", "Image (.png)": "Resim (.png)", - "Image description": "Resim açıklaması", "Image generation progress": "Görüntü oluşturma ilerlemesi", - "Image URL": "Resim URL'si", "Implement live preview with GitHub styling": "GitHub stiliyle canlı önizleme uygulayın", "Import": "İçe Aktarma", "Import complete": "İçe aktarma tamamlandı", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Satır İçi Kod Blokları", "Insert": "Ekle", "Insert Diagram & More": "Diyagram Ekle ve Daha Fazlası", - "Insert image": "Resim ekle", "Insert link": "Bağlantı ekle", "Insert reference": "Referans ekle", "Insert table": "Tablo ekle", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Gizli Çalışma Alanının Kilidini Aç", "Unlock workspace": "Çalışma alanının kilidini açın", "Unlocking…": "Kilit açılıyor…", - "Upload from Device": "Cihazdan Yükleme", "UPPERCASE": "BÜYÜK HARF", "Use": "Kullan", "Use at least 8 characters.": "En az 8 karakter kullanın.", @@ -1027,24 +1020,44 @@ "Collapse": "Daralt", "converted to short links.": "kısa bağlantılara dönüştürüldü.", "Converting embedded images to short links.": "Gömülü görüntüleri kısa bağlantılara dönüştürme.", - "Drop Markdown files or images to add them.": "Eklemek için Markdown dosya veya görseli bırakın.", "Drop to import or insert": "İçe aktarmak veya eklemek için bırakın", "embedded image": "gömülü görüntü", "Embedded images could not be converted. Please try again.": "Gömülü görüntüler dönüştürülemedi. Lütfen tekrar deneyin.", "expanded.": "genişletildi.\nKısa bağlantılar için", - "for short links.": ".", "Image conversion was cancelled because the document changed.": "Belge değiştiği için görüntü dönüştürme iptal edildi.", - "Image insertion was cancelled because the document changed.": "Belge değiştiği için resim ekleme iptal edildi.", - "Image upload cancelled.": "Resim yükleme iptal edildi.", - "Images larger than 25MB cannot be inserted.": "25 MB'tan büyük resimler eklenemez.", - "Markdown files and images are supported": "Markdown dosyaları ve görselleri desteklenir", "MD": "MD", "Open an editable Markdown file before inserting images.": "Görüntüleri eklemeden önce düzenlenebilir bir Markdown dosyasını açın.", - "The image could not be inserted.": "Resim eklenemedi.", "uploaded and inserted with short links.": "kısa bağlantılarla yüklendi ve eklendi.", - "Uploading": "Yükleniyor", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Cihaz görüntüleri, süresi 90 gün sonra dolacak kısa bir genel bağlantı oluşturacak şekilde optimize edilir ve yüklenir. O zamana kadar resmin URL'sine sahip olan herkes resmi görebilir.", "Mermaid diagram": "Denizkızı diyagramı", "Mermaid diagram actions": "Denizkızı diyagramı eylemleri", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Kısa bağlantılar oluşturmak için resimler optimize edilir ve 90 güne kadar Markdown Viewer genel resim deposuna yüklenir. Resim URL'sine sahip olan herkes bunu görüntüleyebilir. Devam etmek?" + "\" aria-label=\"": "\" aria-label = \"", + "cancel": "iptal et", + "Description": "Açıklama", + "Dismiss notification": "Bildirimi reddet", + "Drop Markdown files, images, GIFs, or videos to add them.": "Eklemek için Markdown dosya, resim, GIF veya video bırakın.", + "External Media (URL)": "Harici Medya (URL)", + "Image, GIF, or video": "Resim, GIF veya video", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Resimler, GIF'ler ve videolar, süresi 90 gün sonra dolacak kısa bir genel bağlantı oluşturmak için yüklenir. O zamana kadar medya URL'sine sahip olan herkes bunu görüntüleyebilir. GIF sınırı: 5 MB; video sınırı: 10 MB.", + "Insert image, GIF, or video": "Resim, GIF veya video ekle", + "Markdown files, images, GIFs, and videos are supported": "Markdown dosyaları, resimleri, GIF'leri ve videoları desteklenir", + "Media description": "Medya açıklaması", + "media file": "medya dosyası", + "Media file": "Medya dosyası", + "Media files larger than 25MB cannot be inserted.": "25 MB'tan büyük medya dosyaları eklenemez.", + "Media insertion cancelled": "Medya ekleme iptal edildi", + "Media insertion was cancelled because the document changed.": "Belge değiştiği için medya ekleme işlemi iptal edildi.", + "Media upload cancelled.": "Medya yüklemesi iptal edildi.", + "Media upload complete": "Medya yüklemesi tamamlandı", + "Media upload failed": "Medya yükleme başarısız oldu", + "Media upload progress": "Medya yükleme ilerlemesi", + "Media URL": "Medya URL'si", + "Notifications": "Bildirimler", + "Preparing upload...": "Yüklemeye hazırlanıyor...", + "The document changed during upload.": "Doküman yükleme sırasında değişti.", + "The media file could not be inserted.": "Medya dosyası eklenemedi.", + "Upload Image, GIF, or Video": "Resim, GIF veya Video Yükle", + "Upload media?": "Medya yüklensin mi?", + "Uploading media": "Medya yükleniyor", + "Uploading media file": "Medya dosyası yükleniyor", + "Uploading media files": "Medya dosyaları yükleniyor" } diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index b5b2f2e4..e60ab51f 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -63,7 +63,6 @@ "All changes saved": "已儲存所有更改", "All workspace folders collapsed.": "所有工作區資料夾均已折疊。", "All workspace folders expanded.": "所有工作區資料夾均已展開。", - "Alt Text (used for title)": "替代文字(用於標題)", "Apache License 2.0": "Apache 許可證 2.0", "Appearance": "外觀", "Application information": "申請資訊", @@ -301,7 +300,6 @@ "Export document": "匯出文件", "Export failed:": "匯出失敗:", "Export PDF Options": "匯出 PDF 選項", - "External Image (URL)": "外部影像(URL)", "Faceted Gear Wheel": "多面齒輪", "Failed to copy HTML:": "複製 HTML 失敗:", "Failed to copy Markdown:": "無法複製 Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML 實體", "HTML export failed:": "HTML 匯出失敗:", - "Image": "影像", "Image (.png)": "圖 (.png)", - "Image description": "圖說明", "Image generation progress": "影像生成進度", - "Image URL": "圖片網址", "Implement live preview with GitHub styling": "使用 GitHub 樣式實現即時預覽", "Import": "導入", "Import complete": "導入完成", @@ -433,7 +428,6 @@ "Inline Code Blocks": "內嵌程式碼區塊", "Insert": "插入", "Insert Diagram & More": "插入圖及更多", - "Insert image": "插入圖片", "Insert link": "插入鏈接", "Insert reference": "插入參考", "Insert table": "插入表格", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "解鎖秘密工作區", "Unlock workspace": "解鎖工作區", "Unlocking…": "解鎖中…", - "Upload from Device": "從裝置上傳", "UPPERCASE": "大寫", "Use": "使用", "Use at least 8 characters.": "至少使用 8 個字元。", @@ -1027,24 +1020,44 @@ "Collapse": "折疊", "converted to short links.": "轉換為短連結。", "Converting embedded images to short links.": "將嵌入影像轉換為短連結。", - "Drop Markdown files or images to add them.": "刪除 Markdown 檔案或影像以新增它們。", "Drop to import or insert": "拖曳以匯入或插入", "embedded image": "嵌入影像", "Embedded images could not be converted. Please try again.": "無法轉換嵌入影像。請再試一次。", "expanded.": "擴展。", - "for short links.": "用於短連結。", "Image conversion was cancelled because the document changed.": "由於文件更改,影像轉換被取消。", - "Image insertion was cancelled because the document changed.": "由於文件更改,影像插入被取消。", - "Image upload cancelled.": "圖片上傳已取消。", - "Images larger than 25MB cannot be inserted.": "無法插入大於 25MB 的影像。", - "Markdown files and images are supported": "支援 Markdown 文件和影像", "MD": "MD", "Open an editable Markdown file before inserting images.": "在插入影像之前開啟可編輯的 Markdown 檔案。", - "The image could not be inserted.": "無法插入影像。", "uploaded and inserted with short links.": "已上傳並插入短連結。", - "Uploading": "上傳中", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "設備圖像經過最佳化並上傳,以創建一個簡短的公共鏈接,該鏈接將在 90 天後過期。在此之前,任何知道該圖像 URL 的人都可以查看該圖像。", "Mermaid diagram": "美人魚圖", "Mermaid diagram actions": "人魚圖動作", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "為了創建短鏈接,圖像經過優化並上傳到 Markdown Viewer 公共圖像存儲,保存時間長達 90 天。任何擁有圖像 URL 的人都可以查看它。繼續?" + "\" aria-label=\"": "“詠嘆調標籤=”", + "cancel": "取消", + "Description": "說明", + "Dismiss notification": "關閉通知", + "Drop Markdown files, images, GIFs, or videos to add them.": "拖曳 Markdown 檔案、影像、GIF 或影片來新增它們。", + "External Media (URL)": "外部媒體(URL)", + "Image, GIF, or video": "圖片、GIF 或視頻", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "上傳圖像、GIF 和影片以創建一個簡短的公共鏈接,該鏈接將在 90 天後過期。在此之前,任何知道媒體 URL 的人都可以查看它。 GIF 限制:5 MB;影片限制:10 MB。", + "Insert image, GIF, or video": "插入影像、GIF 或視頻", + "Markdown files, images, GIFs, and videos are supported": "支援 Markdown 文件、圖像、GIF 和視頻", + "Media description": "媒體描述", + "media file": "媒體文件", + "Media file": "媒體文件", + "Media files larger than 25MB cannot be inserted.": "無法插入大於 25MB 的媒體檔案。", + "Media insertion cancelled": "媒體插入已取消", + "Media insertion was cancelled because the document changed.": "由於文件更改,媒體插入被取消。", + "Media upload cancelled.": "媒體上傳已取消。", + "Media upload complete": "媒體上傳完成", + "Media upload failed": "媒體上傳失敗", + "Media upload progress": "媒體上傳進度", + "Media URL": "媒體網址", + "Notifications": "通知", + "Preparing upload...": "正在準備上傳...", + "The document changed during upload.": "文件在上傳過程中發生了變化。", + "The media file could not be inserted.": "無法插入媒體檔案。", + "Upload Image, GIF, or Video": "上傳圖像、GIF 或視頻", + "Upload media?": "上傳媒體?", + "Uploading media": "上傳媒體", + "Uploading media file": "正在上傳媒體文件", + "Uploading media files": "上傳媒體文件" } diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index 16e20081..181eb4be 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -63,7 +63,6 @@ "All changes saved": "Усі зміни збережено", "All workspace folders collapsed.": "Усі папки робочої області згорнуто.", "All workspace folders expanded.": "Усі папки робочої області розгорнуто.", - "Alt Text (used for title)": "Альтернативний текст (використовується для назви)", "Apache License 2.0": "Ліцензія Apache 2.0", "Appearance": "Зовнішній вигляд", "Application information": "Інформація про програму", @@ -301,7 +300,6 @@ "Export document": "Експорт документа", "Export failed:": "Помилка експорту:", "Export PDF Options": "Параметри експорту PDF", - "External Image (URL)": "Зовнішнє зображення (URL)", "Faceted Gear Wheel": "Гранене зубчасте колесо", "Failed to copy HTML:": "Не вдалося скопіювати HTML:", "Failed to copy Markdown:": "Не вдалося скопіювати Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "сутності HTML", "HTML export failed:": "Помилка експорту HTML:", - "Image": "Зображення", "Image (.png)": "Зображення (.png)", - "Image description": "Опис зображення", "Image generation progress": "Прогрес створення зображення", - "Image URL": "URL зображення", "Implement live preview with GitHub styling": "Реалізуйте попередній перегляд за допомогою стилю GitHub", "Import": "Імпорт", "Import complete": "Імпорт завершено", @@ -433,7 +428,6 @@ "Inline Code Blocks": "Вбудовані блоки коду", "Insert": "Вставити", "Insert Diagram & More": "Вставте схему та інше", - "Insert image": "Вставте зображення", "Insert link": "Вставити посилання", "Insert reference": "Вставте посилання", "Insert table": "Вставити таблицю", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "Розблокуйте таємну робочу область", "Unlock workspace": "Розблокувати робочу область", "Unlocking…": "Розблокування…", - "Upload from Device": "Завантажити з пристрою", "UPPERCASE": "ВЕРХНИЙ РЕГІСТ", "Use": "Використовуйте", "Use at least 8 characters.": "Використовуйте принаймні 8 символів.", @@ -1027,24 +1020,44 @@ "Collapse": "Згорнути", "converted to short links.": "перетворено на короткі посилання.", "Converting embedded images to short links.": "Перетворення вбудованих зображень на короткі посилання.", - "Drop Markdown files or images to add them.": "Перетягніть Markdown файли або зображення, щоб додати їх.", "Drop to import or insert": "Перетягніть, щоб імпортувати або вставити", "embedded image": "вбудоване зображення", "Embedded images could not be converted. Please try again.": "Вбудовані зображення не вдалося конвертувати. Спробуйте ще раз.", "expanded.": "розширено.", - "for short links.": "для коротких посилань.", "Image conversion was cancelled because the document changed.": "Перетворення зображення скасовано, оскільки документ змінено.", - "Image insertion was cancelled because the document changed.": "Вставлення зображення скасовано, оскільки документ змінено.", - "Image upload cancelled.": "Завантаження зображення скасовано.", - "Images larger than 25MB cannot be inserted.": "Не можна вставляти зображення розміром понад 25 МБ.", - "Markdown files and images are supported": "Підтримуються файли та зображення Markdown", "MD": "MD", "Open an editable Markdown file before inserting images.": "Відкрийте редагований файл Markdown перед вставленням зображень.", - "The image could not be inserted.": "Не вдалося вставити зображення.", "uploaded and inserted with short links.": "завантажено та вставлено з короткими посиланнями.", - "Uploading": "Завантаження", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "Зображення пристроїв оптимізовано та завантажено для створення короткого загальнодоступного посилання, термін дії якого закінчується через 90 днів. Будь-хто, хто має URL-адресу зображення, може переглянути його до того часу.", "Mermaid diagram": "Діаграма русалки", "Mermaid diagram actions": "Дії діаграми русалок", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "Для створення коротких посилань зображення оптимізуються та завантажуються до загальнодоступного сховища зображень Markdown Viewer на термін до 90 днів. Будь-хто, хто має URL-адресу зображення, може його переглянути. Продовжити?" + "\" aria-label=\"": "\"аріа-мітка=\"", + "cancel": "скасувати", + "Description": "Опис", + "Dismiss notification": "Відхилити сповіщення", + "Drop Markdown files, images, GIFs, or videos to add them.": "Перетягніть Markdown файли, зображення, GIF-файли або відео, щоб додати їх.", + "External Media (URL)": "Зовнішній носій (URL)", + "Image, GIF, or video": "Зображення, GIF або відео", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "Зображення, GIF-файли та відео завантажуються для створення короткого загальнодоступного посилання, термін дії якого закінчується через 90 днів. Будь-хто, хто має URL-адресу медіа, може переглядати його до того часу. Ліміт GIF: 5 МБ; ліміт відео: 10 Мб.", + "Insert image, GIF, or video": "Вставте зображення, GIF або відео", + "Markdown files, images, GIFs, and videos are supported": "Підтримуються Markdown файли, зображення, GIF-файли та відео", + "Media description": "Опис медіа", + "media file": "медіафайл", + "Media file": "Медіафайл", + "Media files larger than 25MB cannot be inserted.": "Не можна вставляти мультимедійні файли розміром понад 25 МБ.", + "Media insertion cancelled": "Вставлення носія скасовано", + "Media insertion was cancelled because the document changed.": "Вставлення носія скасовано, оскільки документ змінено.", + "Media upload cancelled.": "Завантаження медіа скасовано.", + "Media upload complete": "Завантаження мультимедійного файлу завершено", + "Media upload failed": "Помилка завантаження медіа", + "Media upload progress": "Хід завантаження медіа", + "Media URL": "МедіаURL", + "Notifications": "Сповіщення", + "Preparing upload...": "Підготовка завантаження...", + "The document changed during upload.": "Документ змінено під час завантаження.", + "The media file could not be inserted.": "Не вдалося вставити мультимедійний файл.", + "Upload Image, GIF, or Video": "Завантажте зображення, GIF або відео", + "Upload media?": "Завантажити медіа?", + "Uploading media": "Завантаження медіа", + "Uploading media file": "Завантаження мультимедійного файлу", + "Uploading media files": "Завантаження медіафайлів" } diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 65b04427..e631d71d 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -63,7 +63,6 @@ "All changes saved": "已保存所有更改", "All workspace folders collapsed.": "所有工作区文件夹均已折叠。", "All workspace folders expanded.": "所有工作区文件夹均已展开。", - "Alt Text (used for title)": "替代文本(用于标题)", "Apache License 2.0": "Apache 许可证 2.0", "Appearance": "外观", "Application information": "申请信息", @@ -301,7 +300,6 @@ "Export document": "导出文件", "Export failed:": "导出失败:", "Export PDF Options": "导出 PDF 选项", - "External Image (URL)": "外部图像(URL)", "Faceted Gear Wheel": "多面齿轮", "Failed to copy HTML:": "复制 HTML 失败:", "Failed to copy Markdown:": "无法复制 Markdown:", @@ -408,11 +406,8 @@ "HTML": "HTML", "HTML entities": "HTML 实体", "HTML export failed:": "HTML 导出失败:", - "Image": "图像", "Image (.png)": "图片 (.png)", - "Image description": "图片说明", "Image generation progress": "图像生成进度", - "Image URL": "图片网址", "Implement live preview with GitHub styling": "使用 GitHub 样式实现实时预览", "Import": "导入", "Import complete": "导入完成", @@ -433,7 +428,6 @@ "Inline Code Blocks": "内联代码块", "Insert": "插入", "Insert Diagram & More": "插入图及更多", - "Insert image": "插入图片", "Insert link": "插入链接", "Insert reference": "插入参考", "Insert table": "插入表格", @@ -881,7 +875,6 @@ "Unlock Secret Workspace": "解锁秘密工作区", "Unlock workspace": "解锁工作区", "Unlocking…": "解锁中……", - "Upload from Device": "从设备上传", "UPPERCASE": "大写", "Use": "使用", "Use at least 8 characters.": "至少使用 8 个字符。", @@ -1027,24 +1020,44 @@ "Collapse": "折叠", "converted to short links.": "转换为短链接。", "Converting embedded images to short links.": "将嵌入图像转换为短链接。", - "Drop Markdown files or images to add them.": "删除 Markdown 文件或图像以添加它们。", "Drop to import or insert": "拖放以导入或插入", "embedded image": "嵌入图像", "Embedded images could not be converted. Please try again.": "无法转换嵌入图像。请再试一次。", "expanded.": "扩展。", - "for short links.": "用于短链接。", "Image conversion was cancelled because the document changed.": "由于文档更改,图像转换被取消。", - "Image insertion was cancelled because the document changed.": "由于文档更改,图像插入被取消。", - "Image upload cancelled.": "图片上传已取消。", - "Images larger than 25MB cannot be inserted.": "无法插入大于 25MB 的图像。", - "Markdown files and images are supported": "支持 Markdown 文件和图像", "MD": "MD", "Open an editable Markdown file before inserting images.": "在插入图像之前打开可编辑的 Markdown 文件。", - "The image could not be inserted.": "无法插入图像。", "uploaded and inserted with short links.": "已上传并插入短链接。", - "Uploading": "上传中", - "Device images are optimized and uploaded to create a short public link that expires after 90 days. Anyone with the image URL can view it until then.": "设备图像经过优化并上传,以创建一个简短的公共链接,该链接将在 90 天后过期。在此之前,任何知道该图像 URL 的人都可以查看该图像。", "Mermaid diagram": "美人鱼图", "Mermaid diagram actions": "人鱼图动作", - "To create short links, images are optimized and uploaded to Markdown Viewer public image storage for up to 90 days. Anyone with an image URL can view it. Continue?": "为了创建短链接,图像经过优化并上传到 Markdown Viewer 公共图像存储,保存时间长达 90 天。任何拥有图像 URL 的人都可以查看它。继续?" + "\" aria-label=\"": "“咏叹调标签=”", + "cancel": "取消", + "Description": "说明", + "Dismiss notification": "关闭通知", + "Drop Markdown files, images, GIFs, or videos to add them.": "拖放 Markdown 文件、图像、GIF 或视频来添加它们。", + "External Media (URL)": "外部媒体(URL)", + "Image, GIF, or video": "图片、GIF 或视频", + "Images, GIFs, and videos are uploaded to create a short public link that expires after 90 days. Anyone with the media URL can view it until then. GIF limit: 5 MB; video limit: 10 MB.": "上传图像、GIF 和视频以创建一个简短的公共链接,该链接将在 90 天后过期。在此之前,任何知道媒体 URL 的人都可以查看它。 GIF 限制:5 MB;视频限制:10 MB。", + "Insert image, GIF, or video": "插入图像、GIF 或视频", + "Markdown files, images, GIFs, and videos are supported": "支持 Markdown 文件、图像、GIF 和视频", + "Media description": "媒体描述", + "media file": "媒体文件", + "Media file": "媒体文件", + "Media files larger than 25MB cannot be inserted.": "无法插入大于 25MB 的媒体文件。", + "Media insertion cancelled": "媒体插入已取消", + "Media insertion was cancelled because the document changed.": "由于文档更改,媒体插入被取消。", + "Media upload cancelled.": "媒体上传已取消。", + "Media upload complete": "媒体上传完成", + "Media upload failed": "媒体上传失败", + "Media upload progress": "媒体上传进度", + "Media URL": "媒体网址", + "Notifications": "通知", + "Preparing upload...": "正在准备上传...", + "The document changed during upload.": "文档在上传过程中发生了变化。", + "The media file could not be inserted.": "无法插入媒体文件。", + "Upload Image, GIF, or Video": "上传图像、GIF 或视频", + "Upload media?": "上传媒体?", + "Uploading media": "上传媒体", + "Uploading media file": "正在上传媒体文件", + "Uploading media files": "上传媒体文件" } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 2198f4d3..4b0d9378 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -3,7 +3,7 @@ - + @@ -344,7 +344,7 @@

Explorer

Workspaces Drag files to move
-
+
@@ -1005,33 +1005,33 @@

Markdown Viewer

@@ -1403,15 +1403,33 @@

Add feedback

Importing files - 0 of 0 + 0 of 0
Preparing import…
- +
+ + +
+