Правки фронта
1. Добавление тегов 2. Новые алгоритмы
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,4 +5,8 @@ front_vue/node_modules/
|
||||
front_vue/dist/
|
||||
|
||||
# Публичные файлы backend
|
||||
backend/public/*
|
||||
backend/public/*
|
||||
|
||||
# Личные файлы
|
||||
deploy.js
|
||||
deploy.php
|
||||
@@ -44,7 +44,7 @@
|
||||
<span v-if="card.dateCreate" class="date-create">
|
||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||
</span>
|
||||
<span v-if="card.dueDate" class="due-date" :class="dueDateStatus">
|
||||
<span v-if="card.dueDate && Number(columnId) !== 4" class="due-date" :class="dueDateStatus">
|
||||
{{ daysLeftText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -41,12 +41,32 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Подробное описание</label>
|
||||
<textarea
|
||||
v-model="form.details"
|
||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
<div class="field-header">
|
||||
<label>Подробное описание</label>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив (Ctrl+I)">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание (Ctrl+U)">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Редактируемое поле с форматированием -->
|
||||
<div
|
||||
class="details-editor"
|
||||
:class="{ 'is-empty': !form.details }"
|
||||
contenteditable="true"
|
||||
ref="detailsInput"
|
||||
@input="onDetailsInput"
|
||||
@paste="onDetailsPaste"
|
||||
@keydown="onDetailsKeydown"
|
||||
@click="onDetailsClick"
|
||||
:data-placeholder="'Подробное описание задачи, заметки, ссылки...'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -340,6 +360,7 @@ const dropdownRef = ref(null)
|
||||
const searchInput = ref(null)
|
||||
const panelRef = ref(null)
|
||||
const fileInput = ref(null)
|
||||
const detailsInput = ref(null)
|
||||
const dropdownOpen = ref(false)
|
||||
const userSearch = ref('')
|
||||
|
||||
@@ -591,6 +612,9 @@ watch(() => props.show, async (newVal) => {
|
||||
await nextTick()
|
||||
saveInitialForm()
|
||||
|
||||
// Устанавливаем содержимое редактора описания
|
||||
setEditorContent(form.details)
|
||||
|
||||
await nextTick()
|
||||
titleInput.value?.focus()
|
||||
refreshIcons()
|
||||
@@ -831,6 +855,129 @@ const refreshIcons = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Применить форматирование (toggle)
|
||||
const applyFormat = (command) => {
|
||||
detailsInput.value?.focus()
|
||||
document.execCommand(command, false, null)
|
||||
syncDetailsFromEditor()
|
||||
}
|
||||
|
||||
// Синхронизация содержимого редактора с form.details
|
||||
const syncDetailsFromEditor = () => {
|
||||
if (!detailsInput.value) return
|
||||
// Нормализуем переносы строк в <br>
|
||||
let html = detailsInput.value.innerHTML
|
||||
html = html.replace(/<\/div><div>/gi, '<br>')
|
||||
html = html.replace(/<div>/gi, '')
|
||||
html = html.replace(/<\/div>/gi, '')
|
||||
form.details = html
|
||||
}
|
||||
|
||||
// Обработка ввода
|
||||
const onDetailsInput = () => {
|
||||
syncDetailsFromEditor()
|
||||
}
|
||||
|
||||
// Обработка клика (открытие ссылок)
|
||||
const onDetailsClick = (e) => {
|
||||
// Если кликнули по ссылке — открываем в новой вкладке
|
||||
if (e.target.tagName === 'A') {
|
||||
e.preventDefault()
|
||||
window.open(e.target.href, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка вставки (очистка форматирования из внешних источников)
|
||||
const onDetailsPaste = (e) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
document.execCommand('insertText', false, text)
|
||||
// Подсвечиваем ссылки после вставки
|
||||
setTimeout(linkifyContent, 0)
|
||||
}
|
||||
|
||||
// Горячие клавиши
|
||||
const onDetailsKeydown = (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'b') {
|
||||
e.preventDefault()
|
||||
applyFormat('bold')
|
||||
} else if (e.key === 'i') {
|
||||
e.preventDefault()
|
||||
applyFormat('italic')
|
||||
} else if (e.key === 'u') {
|
||||
e.preventDefault()
|
||||
applyFormat('underline')
|
||||
}
|
||||
}
|
||||
|
||||
// После пробела или Enter — подсвечиваем ссылки
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
setTimeout(linkifyContent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическое преобразование URL в ссылки (вызывается после пробела/Enter)
|
||||
const linkifyContent = () => {
|
||||
if (!detailsInput.value) return
|
||||
|
||||
const html = detailsInput.value.innerHTML
|
||||
|
||||
// Ищем URL которые ещё не в тегах <a>
|
||||
// Разбиваем HTML на части: теги <a>...</a> и остальной текст
|
||||
const parts = html.split(/(<a [^>]*>.*?<\/a>)/gi)
|
||||
|
||||
let changed = false
|
||||
const newParts = parts.map(part => {
|
||||
// Если это уже ссылка — не трогаем
|
||||
if (part.startsWith('<a ')) return part
|
||||
|
||||
// Ищем URL в этой части
|
||||
const newPart = part.replace(
|
||||
/(https?:\/\/[^\s<>"]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener">$1</a>'
|
||||
)
|
||||
if (newPart !== part) changed = true
|
||||
return newPart
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
// Сохраняем позицию курсора (в конце)
|
||||
detailsInput.value.innerHTML = newParts.join('')
|
||||
|
||||
// Ставим курсор в конец
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.selectNodeContents(detailsInput.value)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
syncDetailsFromEditor()
|
||||
}
|
||||
}
|
||||
|
||||
// Установка содержимого редактора
|
||||
const setEditorContent = (html) => {
|
||||
if (detailsInput.value) {
|
||||
detailsInput.value.innerHTML = linkifyHtml(html || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Простое преобразование URL в ссылки (без сложных regex)
|
||||
const linkifyHtml = (html) => {
|
||||
if (!html) return ''
|
||||
|
||||
// Если уже есть теги <a> — не трогаем
|
||||
if (html.includes('<a ')) return html
|
||||
|
||||
// Простая замена URL на ссылки
|
||||
return html.replace(
|
||||
/(https?:\/\/[^\s<>"]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener">$1</a>'
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshIcons()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
@@ -967,6 +1114,48 @@ onUpdated(refreshIcons)
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.format-buttons {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.format-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.format-btn:active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.format-btn i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
@@ -1009,6 +1198,73 @@ onUpdated(refreshIcons)
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
/* Редактор описания с форматированием */
|
||||
.details-editor {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.details-editor:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.details-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.details-editor::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.details-editor::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.details-editor::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.details-editor :deep(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details-editor :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details-editor :deep(b),
|
||||
.details-editor :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details-editor :deep(i),
|
||||
.details-editor :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.details-editor :deep(u) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.field textarea::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user