Правки фронта
1. Добавление тегов 2. Новые алгоритмы
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ front_vue/dist/
|
|||||||
|
|
||||||
# Публичные файлы backend
|
# Публичные файлы backend
|
||||||
backend/public/*
|
backend/public/*
|
||||||
|
|
||||||
|
# Личные файлы
|
||||||
|
deploy.js
|
||||||
|
deploy.php
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<span v-if="card.dateCreate" class="date-create">
|
<span v-if="card.dateCreate" class="date-create">
|
||||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||||
</span>
|
</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 }}
|
{{ daysLeftText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,12 +41,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Подробное описание</label>
|
<div class="field-header">
|
||||||
<textarea
|
<label>Подробное описание</label>
|
||||||
v-model="form.details"
|
<div class="format-buttons">
|
||||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
||||||
rows="8"
|
<i data-lucide="bold"></i>
|
||||||
></textarea>
|
</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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -340,6 +360,7 @@ const dropdownRef = ref(null)
|
|||||||
const searchInput = ref(null)
|
const searchInput = ref(null)
|
||||||
const panelRef = ref(null)
|
const panelRef = ref(null)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
const detailsInput = ref(null)
|
||||||
const dropdownOpen = ref(false)
|
const dropdownOpen = ref(false)
|
||||||
const userSearch = ref('')
|
const userSearch = ref('')
|
||||||
|
|
||||||
@@ -591,6 +612,9 @@ watch(() => props.show, async (newVal) => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
saveInitialForm()
|
saveInitialForm()
|
||||||
|
|
||||||
|
// Устанавливаем содержимое редактора описания
|
||||||
|
setEditorContent(form.details)
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
titleInput.value?.focus()
|
titleInput.value?.focus()
|
||||||
refreshIcons()
|
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(() => {
|
onMounted(() => {
|
||||||
refreshIcons()
|
refreshIcons()
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
@@ -967,6 +1114,48 @@ onUpdated(refreshIcons)
|
|||||||
letter-spacing: 0.5px;
|
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 {
|
.field input {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
@@ -1009,6 +1198,73 @@ onUpdated(refreshIcons)
|
|||||||
transition: border-color 0.15s, background 0.15s;
|
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 {
|
.field textarea::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user