361 lines
27 KiB
HTML
361 lines
27 KiB
HTML
{% extends 'core/base.html' %}
|
||
{% load querystring i18n %}
|
||
{% block toolbar_filter %}
|
||
<div class="toolbar-filter d-md-none">
|
||
<div class="filter-panel">
|
||
<div class="fw-semibold filter-panel-title">{% trans "Filter" %}</div>
|
||
<div class="filter-row">
|
||
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_status == 'due' %}btn-danger{% else %}btn-outline-danger{% endif %}" href="{% if active_status == 'due' %}{% query_replace status=None %}{% else %}{% query_replace status='due' %}{% endif %}">{% trans "Due" %} <span class="badge {% if active_status == 'due' %}text-bg-light{% else %}text-bg-danger{% endif %}">{{ due_count }}</span>{% if active_status == 'due' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">{% trans "Archive" %}{% if active_status == 'archive' %} ×{% endif %}</a>
|
||
<input id="mobile-filter-toggle" class="mobile-filter-toggle" type="checkbox">
|
||
<label for="mobile-filter-toggle" class="mobile-filter-more" title="Filter bearbeiten">☰{% if q or active_tags %}<span class="badge text-bg-secondary ms-1">!</span>{% endif %}</label>
|
||
<div class="mobile-filter-details">
|
||
{% if q or active_tags %}<div class="small text-muted mb-2">{% if q %}„{{ q }}”{% endif %}{% for tag_name in active_tags %} #{{ tag_name }}{% endfor %}</div>{% endif %}
|
||
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
|
||
{% for tag_name in active_tags %}<input type="hidden" name="tag" value="{{ tag_name }}">{% endfor %}
|
||
{% if active_tag_mode == 'all' %}<input type="hidden" name="tag_mode" value="all">{% endif %}
|
||
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
|
||
<div class="input-group input-group-sm">
|
||
<input x-ref="search" class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search (Ctrl+F, # for tags)' %}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
|
||
{% if q %}<a class="btn btn-outline-secondary" href="{% query_replace q=None %}" title="{% trans 'Clear search' %}">×</a>{% endif %}
|
||
</div>
|
||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
|
||
</div>
|
||
</form>
|
||
{% if active_tags|length > 1 %}<div class="btn-group btn-group-sm mb-2"><a class="btn {% if active_tag_mode == 'any' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode=None %}">{% trans "any" %}</a><a class="btn {% if active_tag_mode == 'all' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode='all' %}">{% trans "all" %}</a></div>{% endif %}
|
||
<div class="d-flex flex-wrap gap-2">
|
||
{% for tag in tags %}{% if tag.name in active_tags %}<a class="badge rounded-pill text-bg-dark text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>{% else %}<a class="badge rounded-pill text-bg-light text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>{% endif %}{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
{% block content %}
|
||
<div class="mobile-create-nav gap-2 mb-3">
|
||
<a class="btn btn-sm btn-primary flex-fill" href="{% url 'new_item' %}?kind=todo&next={{ request.get_full_path|urlencode }}">{% trans "Task" %}</a>
|
||
<a class="btn btn-sm btn-secondary flex-fill" href="{% url 'new_item' %}?kind=note&next={{ request.get_full_path|urlencode }}">{% trans "Note" %}</a>
|
||
<a class="btn btn-sm btn-success flex-fill" href="{% url 'new_item' %}?kind=link&next={{ request.get_full_path|urlencode }}">Link</a>
|
||
<a class="btn btn-sm btn-info flex-fill" href="{% url 'new_item' %}?kind=journal&next={{ request.get_full_path|urlencode }}">Journal</a>
|
||
</div>
|
||
<div class="row g-4">
|
||
<aside class="col-lg-3 order-lg-2 mobile-filter-sidebar">
|
||
<div class="filter-panel">
|
||
<div class="d-flex justify-content-between align-items-center mb-2 filter-panel-title"><div class="fw-semibold">{% trans "Filter" %}</div><a class="btn btn-sm btn-outline-secondary" href="{% url 'export_items' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-boost="false">{% trans "Export" %}</a></div>
|
||
<div class="d-flex flex-wrap gap-2 mb-3 filter-buttons">
|
||
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_status == 'due' %}btn-danger{% else %}btn-outline-danger{% endif %}" href="{% if active_status == 'due' %}{% query_replace status=None %}{% else %}{% query_replace status='due' %}{% endif %}">{% trans "Due" %} <span class="badge {% if active_status == 'due' %}text-bg-light{% else %}text-bg-danger{% endif %}">{{ due_count }}</span>{% if active_status == 'due' %} ×{% endif %}</a>
|
||
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">{% trans "Archive" %}{% if active_status == 'archive' %} ×{% endif %}</a>
|
||
</div>
|
||
<form class="mb-3 position-relative d-none d-md-block" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:keydown.window="if(($event.ctrlKey||$event.metaKey)&&$event.key==='f'){ $event.preventDefault(); $refs.search.focus(); $refs.search.select(); }">
|
||
{% for tag_name in active_tags %}<input type="hidden" name="tag" value="{{ tag_name }}">{% endfor %}
|
||
{% if active_tag_mode == 'all' %}<input type="hidden" name="tag_mode" value="all">{% endif %}
|
||
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
|
||
<div class="input-group input-group-sm">
|
||
<input x-ref="search" class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search (Ctrl+F, # for tags)' %}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
|
||
{% if q %}<a class="btn btn-outline-secondary" href="{% query_replace q=None %}" title="{% trans 'Clear search' %}">×</a>{% endif %}
|
||
</div>
|
||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
|
||
</div>
|
||
</form>
|
||
<div class="d-none d-md-block">
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<div class="fw-semibold">{% trans "Tags" %}</div>
|
||
{% if active_tags|length > 1 %}
|
||
<div class="btn-group btn-group-sm" title="{% trans 'Tag mode' %}">
|
||
<a class="btn {% if active_tag_mode == 'any' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode=None %}">{% trans "any" %}</a>
|
||
<a class="btn {% if active_tag_mode == 'all' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode='all' %}">{% trans "all" %}</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML">
|
||
{% include 'core/_tags.html' %}
|
||
</div>
|
||
</div>
|
||
<details class="d-md-none mobile-filter-more">
|
||
<summary class="btn btn-sm btn-outline-secondary" title="Filter bearbeiten">☰{% if q or active_tags %}<span class="badge text-bg-secondary ms-1">!</span>{% endif %}</summary>
|
||
<div class="mobile-filter-details">
|
||
{% if q or active_tags %}<div class="small text-muted mb-2">{% if q %}„{{ q }}”{% endif %}{% for tag_name in active_tags %} #{{ tag_name }}{% endfor %}</div>{% endif %}
|
||
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
|
||
{% for tag_name in active_tags %}<input type="hidden" name="tag" value="{{ tag_name }}">{% endfor %}
|
||
{% if active_tag_mode == 'all' %}<input type="hidden" name="tag_mode" value="all">{% endif %}
|
||
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
|
||
<div class="input-group input-group-sm">
|
||
<input x-ref="search" class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search (Ctrl+F, # for tags)' %}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
|
||
{% if q %}<a class="btn btn-outline-secondary" href="{% query_replace q=None %}" title="{% trans 'Clear search' %}">×</a>{% endif %}
|
||
</div>
|
||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
|
||
</div>
|
||
</form>
|
||
{% if active_tags|length > 1 %}<div class="btn-group btn-group-sm mb-2"><a class="btn {% if active_tag_mode == 'any' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode=None %}">{% trans "any" %}</a><a class="btn {% if active_tag_mode == 'all' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode='all' %}">{% trans "all" %}</a></div>{% endif %}
|
||
<div class="d-flex flex-wrap gap-2">
|
||
{% for tag in tags %}
|
||
{% if tag.name in active_tags %}<a class="badge rounded-pill text-bg-dark text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>{% else %}<a class="badge rounded-pill text-bg-light text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>{% endif %}
|
||
{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</aside>
|
||
<section class="col-lg-9">
|
||
<form class="desktop-quick-create position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in composer_tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){let kind=$el.querySelector('[name=default_item_kind]')?.value;text='';open=false;$el.reset();if(kind)$el.querySelector('[name=default_item_kind]').value=kind}">
|
||
{% csrf_token %}
|
||
<fieldset :disabled="editingItem" :class="{'opacity-50': editingItem}">
|
||
<div class="small text-muted mb-2" x-show="editingItem" x-cloak>{% trans "Inline editor is open; quick entry is disabled." %}</div>
|
||
<div class="mobile-create-buttons gap-2 mb-2">
|
||
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">{% trans "Task" %}</button>
|
||
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">{% trans "Note" %}</button>
|
||
<button type="button" class="btn btn-sm btn-success flex-fill" @click="insert('/link')">Link</button>
|
||
<button type="button" class="btn btn-sm btn-info flex-fill" @click="insert('/journal')">Journal</button>
|
||
</div>
|
||
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in composer_tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
|
||
{{ form.content }}
|
||
<div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
|
||
</div>
|
||
{% include 'core/_format_toolbar.html' %}
|
||
<div class="d-flex align-items-center gap-2 mt-2 quick-actions">
|
||
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
|
||
<div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
|
||
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">{% trans "Editor" %}</a>
|
||
<button class="btn btn-dark px-4">{% trans "Save" %}</button>
|
||
</div>
|
||
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">{% trans "Image from clipboard:" %}</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
|
||
</fieldset>
|
||
</form>
|
||
<div id="items" class="vstack gap-3">
|
||
{% if active_status == 'due' %}
|
||
{% for item in due_now_items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-4">{% trans "No due tasks." %}</div>{% endfor %}
|
||
{% if upcoming_due_items %}<div class="small text-muted text-uppercase fw-semibold mt-2">{% trans "Due in the next 7 days" %}</div>{% endif %}
|
||
{% for item in upcoming_due_items %}{% include 'core/_item.html' %}{% endfor %}
|
||
{% else %}
|
||
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %}
|
||
{% endif %}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
function quickComposer(tags, itemId=null){
|
||
return {
|
||
open:false,
|
||
showFormatting:false,
|
||
text:'',
|
||
active:0,
|
||
query:'',
|
||
queryStart:0,
|
||
currentEl:null,
|
||
preview:null,
|
||
linkSuggestions:[],
|
||
attachmentSuggestions:[],
|
||
userSuggestions:[],
|
||
pendingFiles:[],
|
||
editingItem:false,
|
||
itemId:itemId,
|
||
tags:tags||[],
|
||
init(){
|
||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||
if(event.detail.successful && event.target === this.$el){ this.preview=null; }
|
||
});
|
||
document.body.addEventListener('htmx:afterSwap', () => {
|
||
this.editingItem=!!document.querySelector('.inline-edit-form');
|
||
});
|
||
document.body.addEventListener('tagsChanged', () => {
|
||
fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.tags||[]});
|
||
});
|
||
},
|
||
commands:[
|
||
{token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'},
|
||
{token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'},
|
||
{token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'},
|
||
{token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'},
|
||
{token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'},
|
||
{token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'},
|
||
{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'},
|
||
{token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'},
|
||
{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},
|
||
{token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},
|
||
{token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},
|
||
{token:'/image',label:'{% trans "Insert uploaded image" %}',group:'{% trans "References" %}'},
|
||
{token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},
|
||
{token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}
|
||
],
|
||
get filtered(){
|
||
let q=this.query.toLowerCase();
|
||
let selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase();
|
||
let typeTokens=['/todo','/note','/link','/journal'];
|
||
let dateTokens=['/datum','/morgen','/übermorgen','/nächstewoche'];
|
||
let commands=this.commands.filter(command=>{
|
||
if(typeTokens.includes(command.token)) return !selectedType&&this.queryStart===0;
|
||
if(this.queryStart===0&&!selectedType) return false;
|
||
if(selectedType&&selectedType!=='todo'&&dateTokens.includes(command.token)) return false;
|
||
return true;
|
||
});
|
||
let list=q.startsWith('#')
|
||
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'}))
|
||
: q.startsWith('@')
|
||
? this.userSuggestions.map(u=>({token:'@'+u.username,label:u.label,group:'{% trans "Users" %}'}))
|
||
: q.startsWith('![[')
|
||
? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'})))
|
||
: q.startsWith('[[')
|
||
? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`,group:'{% trans "References" %}'}))
|
||
: commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
|
||
return list.length?list:[{token:this.query,label:'neu',group:''}];
|
||
},
|
||
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
|
||
onInput(e){this.update(e.target)},
|
||
handlePaste(e){
|
||
let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
||
if(!item) return;
|
||
let file=item.getAsFile();
|
||
let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');
|
||
let named=new File([file], `paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`, {type:file.type});
|
||
let dt=new DataTransfer();
|
||
[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));
|
||
dt.items.add(named);
|
||
this.$refs.fileInput.files=dt.files;
|
||
this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));
|
||
this.preview=URL.createObjectURL(named);
|
||
},
|
||
previewFile(e){
|
||
this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));
|
||
let file=this.pendingFiles[0];
|
||
this.preview=file?URL.createObjectURL(file):null;
|
||
},
|
||
onKeydown(e){
|
||
this.currentEl=e.target;
|
||
let shortcut=(e.ctrlKey||e.metaKey)&&{b:'bold',i:'italic',u:'underline'}[e.key.toLowerCase()];
|
||
if(shortcut){
|
||
e.preventDefault();
|
||
this.formatSelection(shortcut);
|
||
return;
|
||
}
|
||
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){
|
||
e.preventDefault();
|
||
this.open=false;
|
||
e.target.form.requestSubmit();
|
||
return;
|
||
}
|
||
if(this.open&&e.key==='ArrowDown'){
|
||
e.preventDefault();
|
||
this.active=(this.active+1)%this.filtered.length;
|
||
return;
|
||
}
|
||
if(this.open&&e.key==='ArrowUp'){
|
||
e.preventDefault();
|
||
this.active=(this.active-1+this.filtered.length)%this.filtered.length;
|
||
return;
|
||
}
|
||
if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){
|
||
e.preventDefault();
|
||
this.insert(this.filtered[this.active].token);
|
||
return;
|
||
}
|
||
if(e.key==='Escape'){
|
||
this.open=false;
|
||
return;
|
||
}
|
||
if(e.key===' '){
|
||
this.open=false;
|
||
return;
|
||
}
|
||
setTimeout(()=>this.update(e.target),0);
|
||
},
|
||
update(el){
|
||
this.currentEl=el;
|
||
if(this.isContentField(el)) this.text=el.value;
|
||
let before=el.value.slice(0,el.selectionStart);
|
||
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
|
||
this.query=fragment;
|
||
this.queryStart=el.selectionStart-fragment.length;
|
||
this.active=0;
|
||
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('@')||fragment.startsWith('[[')||fragment.startsWith('![[');
|
||
if(fragment.startsWith('@')){
|
||
fetch(`{% url "api_user_suggestions" %}?q=${encodeURIComponent(fragment.slice(1))}`).then(r=>r.json()).then(data=>{this.userSuggestions=data.users||[]});
|
||
}else if(fragment.startsWith('![[')&&this.itemId){
|
||
fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(data=>{this.attachmentSuggestions=data.attachments||[]});
|
||
}else if(fragment.startsWith('[[')){
|
||
let q=fragment.slice(2);
|
||
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
|
||
}
|
||
},
|
||
formatSelection(kind){
|
||
let el=this.currentEl&&this.currentEl.matches('textarea')?this.currentEl:this.$el.querySelector('textarea[name="content"],textarea');
|
||
if(!el) return;
|
||
let start=el.selectionStart??0;
|
||
let end=el.selectionEnd??start;
|
||
let selected=el.value.slice(start,end);
|
||
let wrappers={bold:['**','**','Text'],italic:['*','*','Text'],underline:['<u>','</u>','Text'],code:['`','`','Code']};
|
||
let value,newStart,newEnd;
|
||
if(wrappers[kind]){
|
||
let [before,after,placeholder]=wrappers[kind];
|
||
let inner=selected||placeholder;
|
||
value=el.value.slice(0,start)+before+inner+after+el.value.slice(end);
|
||
newStart=start+before.length;
|
||
newEnd=newStart+inner.length;
|
||
}else{
|
||
let prefixes={heading:'## ',quote:'> ',list:'- ',numbered:'1. '};
|
||
let prefix=prefixes[kind]||'';
|
||
let inner=selected||'{% trans "Text" %}';
|
||
let formatted=inner.split('\n').map(line=>prefix+line).join('\n');
|
||
value=el.value.slice(0,start)+formatted+el.value.slice(end);
|
||
newStart=start;
|
||
newEnd=start+formatted.length;
|
||
}
|
||
el.value=value;
|
||
if(this.isContentField(el)) this.text=value;
|
||
this.$nextTick(()=>{el.focus();el.setSelectionRange(newStart,newEnd)});
|
||
},
|
||
addToken(token){
|
||
let el=this.currentEl || document.getElementById('id_content');
|
||
if(!el) return;
|
||
let pos=el.selectionStart??el.value.length;
|
||
let before=el.value.slice(0,pos);
|
||
let after=el.value.slice(pos);
|
||
let prefix=before&&!before.endsWith(' ')&&!before.endsWith('\n')?' ':'';
|
||
let value=before+prefix+token+' '+after;
|
||
if(this.isContentField(el)) this.text=value;
|
||
el.value=value;
|
||
this.showTags=false;
|
||
this.$nextTick(()=>{let p=before.length+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})
|
||
},
|
||
insert(token){
|
||
let el=this.currentEl || document.getElementById('id_content');
|
||
if(!el) return;
|
||
let isCode=token==='/code';
|
||
let isImage=token==='/image';
|
||
if(isCode) token='```\n\n```';
|
||
if(isImage) token=this.pendingFiles.length?`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`:'![[';
|
||
let pos=el.selectionStart;
|
||
let before=el.value.slice(0,pos);
|
||
let after=el.value.slice(pos);
|
||
let match=before.match(/(^|\s)([^\s]*)$/);
|
||
let start=match?pos-match[2].length:pos;
|
||
let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';
|
||
let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');
|
||
let suffix=(isCode||token==='![[')?'':' ';
|
||
let value=el.value.slice(0,start)+prefix+token+suffix+after;
|
||
if(this.isContentField(el)) this.text=value;
|
||
el.value=value;
|
||
this.open=false;
|
||
this.$nextTick(()=>{
|
||
let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;
|
||
el.focus();
|
||
el.setSelectionRange(p,p);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|