feat(index): paste photos and project files from clipboard with Ctrl+V
- handle document paste: images become the entry photo when none is set, otherwise they land in "Файлы проекта" along with any other pasted files - take file names from the clipboard, replace generic ones (image.png, blob) with "Вставка <date>.<ext>"; derive extension from MIME when missing - validate client-side like the server: 10 files, 10 MB per file, 30 MB total, reject blocked extensions (*.html, *.js, *.svg, ...) - share the limit logic between the picker and paste, send the photo under its real name so pasted PNG/JPEG keep their extension - add Ctrl+V hints to the photo and files cards (hidden on touch devices)
This commit is contained in:
@@ -39,6 +39,9 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text
|
|||||||
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
||||||
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
||||||
.files-list .file-row button:hover{color:#ef4444}
|
.files-list .file-row button:hover{color:#ef4444}
|
||||||
|
.paste-hint{font-size:.75rem;color:var(--muted);line-height:1.35;text-align:center}
|
||||||
|
.files-card .paste-hint{text-align:left}
|
||||||
|
@media(hover:none){.paste-hint{display:none}}
|
||||||
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||||
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
||||||
.preview.has-photo{border-style:solid}
|
.preview.has-photo{border-style:solid}
|
||||||
@@ -110,6 +113,7 @@ nav a:hover{color:var(--text)}
|
|||||||
<div class="photo-btns">
|
<div class="photo-btns">
|
||||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="paste-hint" id="photoPasteHint">Ctrl+V — вставить фото из буфера</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="files-card">
|
<div class="files-card">
|
||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
@@ -118,6 +122,7 @@ nav a:hover{color:var(--text)}
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||||
<input type="file" id="filesInput" multiple hidden>
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
|
<p class="paste-hint">Ctrl+V — вставить файлы из буфера</p>
|
||||||
<div class="files-list" id="filesList"></div>
|
<div class="files-list" id="filesList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+118
-6
@@ -13,7 +13,9 @@ const toast = document.getElementById('toast');
|
|||||||
const camVideo = document.getElementById('camVideo');
|
const camVideo = document.getElementById('camVideo');
|
||||||
const camCanvas = document.getElementById('camCanvas');
|
const camCanvas = document.getElementById('camCanvas');
|
||||||
const camModal = document.getElementById('camModal');
|
const camModal = document.getElementById('camModal');
|
||||||
|
const photoPasteHint = document.getElementById('photoPasteHint');
|
||||||
let capturedBlob = null;
|
let capturedBlob = null;
|
||||||
|
let capturedName = 'photo.jpg';
|
||||||
let stream = null;
|
let stream = null;
|
||||||
const descInput = document.getElementById('descInput');
|
const descInput = document.getElementById('descInput');
|
||||||
const filesInput = document.getElementById('filesInput');
|
const filesInput = document.getElementById('filesInput');
|
||||||
@@ -118,13 +120,119 @@ function clearFiles() {
|
|||||||
renderFiles();
|
renderFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 30 * 1024 * 1024;
|
||||||
|
const BLOCKED_FILE_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||||
|
const IMAGE_FILE_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.ico', '.heic', '.heif', '.jfif']);
|
||||||
|
const MIME_EXT = {
|
||||||
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp',
|
||||||
|
'image/bmp': '.bmp', 'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif', 'image/svg+xml': '.svg',
|
||||||
|
'application/pdf': '.pdf', 'application/zip': '.zip', 'application/x-zip-compressed': '.zip',
|
||||||
|
'application/msword': '.doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||||
|
'application/vnd.ms-excel': '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': '.ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
||||||
|
'text/plain': '.txt', 'text/csv': '.csv', 'application/rtf': '.rtf',
|
||||||
|
'application/x-rar-compressed': '.rar', 'application/vnd.rar': '.rar', 'application/x-7z-compressed': '.7z'
|
||||||
|
};
|
||||||
|
let notifyTimer = null;
|
||||||
|
|
||||||
|
function notify(text, isError) {
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.classList.add('show');
|
||||||
|
toast.classList.toggle('error', !!isError);
|
||||||
|
clearTimeout(notifyTimer);
|
||||||
|
notifyTimer = setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extOf(name) {
|
||||||
|
const s = String(name || '');
|
||||||
|
const i = s.lastIndexOf('.');
|
||||||
|
return i > 0 ? s.slice(i).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromFile(file) {
|
||||||
|
const named = extOf(file.name);
|
||||||
|
if (/^\.[a-z0-9]{1,8}$/i.test(named)) return named;
|
||||||
|
const byMime = MIME_EXT[String(file.type || '').toLowerCase()];
|
||||||
|
if (byMime) return byMime;
|
||||||
|
const sub = String(file.type || '').split('/')[1] || '';
|
||||||
|
return /^[a-z0-9]{1,8}$/i.test(sub) ? '.' + sub : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteFileName(file) {
|
||||||
|
const base = String(file.name || '').trim();
|
||||||
|
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
|
||||||
|
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||||
|
return `Вставка ${stamp}${extFromFile(file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalUploadBytes() {
|
||||||
|
return (capturedBlob ? capturedBlob.size : 0) + selectedFiles.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(list) {
|
||||||
|
const added = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const file of list) {
|
||||||
|
if (selectedFiles.length >= MAX_FILES) { skipped.push('Можно прикрепить не более 10 файлов'); break; }
|
||||||
|
if (file.size > MAX_FILE_BYTES) { skipped.push(`«${file.name}» больше 10 МБ`); continue; }
|
||||||
|
if (BLOCKED_FILE_EXT.test(extOf(file.name))) { skipped.push(`«${file.name}»: недопустимый тип файла`); continue; }
|
||||||
|
if (totalUploadBytes() + file.size > MAX_TOTAL_BYTES) { skipped.push('Суммарный размер файлов — не более 30 МБ'); break; }
|
||||||
|
selectedFiles.push(file);
|
||||||
|
added.push(file.name);
|
||||||
|
}
|
||||||
|
if (added.length) renderFiles();
|
||||||
|
return { added, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
filesInput.addEventListener('change', () => {
|
filesInput.addEventListener('change', () => {
|
||||||
const keep = 10 - selectedFiles.length;
|
const res = addFiles([...filesInput.files]);
|
||||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
||||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
||||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
||||||
filesInput.value = '';
|
filesInput.value = '';
|
||||||
renderFiles();
|
if (res.skipped.length) alert(res.skipped[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
if (form.style.display === 'none') return;
|
||||||
|
const clip = e.clipboardData;
|
||||||
|
if (!clip) return;
|
||||||
|
const pasted = [];
|
||||||
|
for (const item of Array.from(clip.items || [])) {
|
||||||
|
if (item.kind !== 'file') continue;
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) pasted.push(file);
|
||||||
|
}
|
||||||
|
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
|
||||||
|
if (!pasted.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
let photoSet = false;
|
||||||
|
let filesAdded = 0;
|
||||||
|
const skipped = [];
|
||||||
|
for (const raw of pasted) {
|
||||||
|
const name = pasteFileName(raw);
|
||||||
|
const file = raw.name === name ? raw : new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
|
||||||
|
if (!capturedBlob && IMAGE_FILE_EXT.has(extFromFile(file))) {
|
||||||
|
capturedBlob = file;
|
||||||
|
capturedName = name;
|
||||||
|
showPreview(URL.createObjectURL(file));
|
||||||
|
photoSet = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res = addFiles([file]);
|
||||||
|
filesAdded += res.added.length;
|
||||||
|
skipped.push(...res.skipped);
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (photoSet) parts.push('Фото вставлено из буфера');
|
||||||
|
if (filesAdded) parts.push(filesAdded === 1 ? 'Файл добавлен' : `Файлов добавлено: ${filesAdded}`);
|
||||||
|
if (!parts.length) {
|
||||||
|
if (skipped.length) notify(skipped[0], true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify(parts.join('. ') + (skipped.length ? '. ' + skipped[0] : ''), skipped.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -231,13 +339,16 @@ function showPreview(src) {
|
|||||||
preview.src = src;
|
preview.src = src;
|
||||||
preview.classList.add('has-photo');
|
preview.classList.add('has-photo');
|
||||||
removePhotoBtn.classList.add('show');
|
removePhotoBtn.classList.add('show');
|
||||||
|
photoPasteHint.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePhoto() {
|
function removePhoto() {
|
||||||
preview.src = placeholderSrc;
|
preview.src = placeholderSrc;
|
||||||
preview.classList.remove('has-photo');
|
preview.classList.remove('has-photo');
|
||||||
removePhotoBtn.classList.remove('show');
|
removePhotoBtn.classList.remove('show');
|
||||||
|
photoPasteHint.style.display = '';
|
||||||
capturedBlob = null;
|
capturedBlob = null;
|
||||||
|
capturedName = 'photo.jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCam() {
|
async function openCam() {
|
||||||
@@ -264,6 +375,7 @@ function capture() {
|
|||||||
ctx.drawImage(camVideo, 0, 0);
|
ctx.drawImage(camVideo, 0, 0);
|
||||||
camCanvas.toBlob(blob => {
|
camCanvas.toBlob(blob => {
|
||||||
capturedBlob = blob;
|
capturedBlob = blob;
|
||||||
|
capturedName = 'photo.jpg';
|
||||||
showPreview(URL.createObjectURL(blob));
|
showPreview(URL.createObjectURL(blob));
|
||||||
closeCam();
|
closeCam();
|
||||||
}, 'image/jpeg', captureQuality);
|
}, 'image/jpeg', captureQuality);
|
||||||
@@ -292,7 +404,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
btn.textContent = 'Отправка...';
|
btn.textContent = 'Отправка...';
|
||||||
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('photo', capturedBlob, 'photo.jpg');
|
fd.append('photo', capturedBlob, capturedName);
|
||||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
|
|||||||
Reference in New Issue
Block a user