@@ -1,5 +1,5 @@
< script setup lang = "ts" >
import { computed , nextTick , onMounted , ref , watch } from "vue" ;
import { computed , nextTick , onBeforeUnmount , onMounted , ref , watch } from "vue" ;
import { api } from "../api/client" ;
import { useNotesStore } from "../stores/notes" ;
import { useTitlesStore , type TitleEntry } from "../stores/titles" ;
@@ -11,67 +11,246 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import type { Note , NoteLabel } from "../stores/notes" ;
import { LABEL _CHIP _CLASSES , type NoteColor } from "../notes/colors" ;
const props = defineProps < { note : Note } > ( ) ;
// One editor for BOTH composing and editing. `inline` renders the board composer
// frame (collapsible, in-flow); the default renders the modal editor. `note` = the
// note being edited, or null to compose a new one. In compose mode there is no note
// id until a draft is persisted — on commit-with-content, or on the first rich action
// (label / image / reminder / checklist) — so we never litter empty notes.
const props = withDefaults ( defineProps < { note ? : Note | null ; inline ? : boolean ; autofocus ? : boolean } > ( ) , {
note : null ,
inline : false ,
autofocus : false ,
} ) ;
const emit = defineEmits < { ( e : "close" ) : void ; ( e : "navigate" , id : string ) : void } > ( ) ;
const notes = useNotesStore ( ) ;
const titles = useTitlesStore ( ) ;
// Read the note reactively from the store so checklist item add/toggle/delete
// (which reconcile a fresh note object) reflect live while the editor is open.
const liveNote = computed ( ( ) => notes . items . find ( ( n ) => n . id === props . note . id ) ? ? props . note ) ;
const title = ref ( props . note . title ? ? "" ) ;
const body = ref ( props . note . body ) ;
const color = ref < NoteColor > ( props . note . color ) ;
const labelList = ref < NoteLabel [ ] > ( [ ... props . note . labels ] ) ;
const noteId = ref < string | null > ( props . note ? . id ? ? null ) ;
const title = ref ( props . note ? . title ? ? "" ) ;
const body = ref ( props . note ? . body ? ? "" ) ;
const color = ref < NoteColor > ( props . note ? . color ? ? "default" ) ;
const labelList = ref < NoteLabel [ ] > ( props . note ? [ ... props . note . labels ] : [ ] ) ;
const createKind = ref < "text" | "list" > ( "text" ) ; // compose-only list toggle
const expanded = ref ( ! props . inline ) ; // modal is always open; inline starts collapsed
const saving = ref ( false ) ;
const root = ref < HTMLElement | null > ( null ) ;
const bodyInput = ref < HTMLTextAreaElement | null > ( null ) ;
const fileInput = ref < HTMLInputElement | null > ( null ) ;
const uploadError = ref ( "" ) ;
const backlinks = ref < { id : string ; title : string } [ ] > ( [ ] ) ;
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref < { title : string | null ; body : string ; color : NoteColor } > ( {
title : props . note ? . title ? ? null ,
body : props . note ? . body ? ? "" ,
color : ( props . note ? . color ? ? "default" ) as NoteColor ,
} ) ;
const isCreate = computed ( ( ) => noteId . value === null ) ;
const hasContent = computed ( ( ) => title . value . trim ( ) !== "" || body . value . trim ( ) !== "" ) ;
// Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed ( ( ) => ! isCreate . value || hasContent . value ) ;
// A synthetic note for compose mode (before anything is persisted), so the shared
// template can read attachments/items/kind/remind_at uniformly.
const draftNote = computed < Note > ( ( ) => ( {
id : "" ,
title : title . value . trim ( ) || null ,
display _title : "" ,
body : body . value ,
color : color . value ,
kind : createKind . value ,
position : 0 ,
pinned : false ,
archived : false ,
trashed : false ,
remind _at : null ,
labels : labelList . value ,
items : [ ] ,
attachments : [ ] ,
created _at : null ,
updated _at : null ,
} ) ) ;
const liveNote = computed < Note > ( ( ) =>
noteId . value
? ( notes . items . find ( ( n ) => n . id === noteId . value ) ? ? props . note ? ? draftNote . value )
: draftNote . value ,
) ;
// Only edit-mode list notes render the interactive checklist; compose-list types
// lines into the textarea (they become items on create).
const showChecklist = computed ( ( ) => ! isCreate . value && liveNote . value . kind === "list" ) ;
const isListMode = computed ( ( ) => ( isCreate . value ? createKind . value === "list" : liveNote . value . kind === "list" ) ) ;
const bodyPlaceholder = computed ( ( ) =>
isCreate . value && createKind . value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)" ,
) ;
// Keep local state in sync when the edited note changes (modal reused for another note).
watch (
( ) => props . note ,
( n ) => {
title . value = n . title ? ? "" ;
body . value = n . body ;
color . value = n . color ;
labelList . value = [ ... n . labels ] ;
noteId . value = n ? . id ? ? null ;
title . value = n ? . title ? ? "" ;
body . value = n ? . body ? ? "" ;
color . value = ( n ? . color ? ? "default" ) as NoteColor ;
labelList . value = n ? [ ... n . labels ] : [ ] ;
baseline . value = { title : n ? . title ? ? null , body : n ? . body ? ? "" , color : ( n ? . color ? ? "default" ) as NoteColor } ;
} ,
) ;
const backlinks = ref < { id : string ; title : string } [ ] > ( [ ] ) ;
// ---- persistence ----
async function createFromFields ( ) : Promise < void > {
let created : Note ;
if ( createKind . value === "list" ) {
const items = body . value
. split ( "\n" )
. map ( ( l ) => l . trim ( ) )
. filter ( Boolean ) ;
created = await notes . create ( { title : title . value , body : "" , color : color . value , kind : "list" , items } ) ;
body . value = "" ; // the lines moved into checklist items
} else {
created = await notes . create ( { title : title . value , body : body . value , color : color . value } ) ;
}
noteId . value = created . id ;
baseline . value = { title : created . title , body : created . body , color : created . color as NoteColor } ;
}
async function loadBacklinks ( ) {
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
// null when there's nothing to create yet (empty compose — callers just no-op).
async function ensureDraft ( ) : Promise < string | null > {
if ( noteId . value ) return noteId . value ;
if ( ! hasContent . value ) return null ;
await createFromFields ( ) ;
return noteId . value ;
}
// Persist current text fields: create in compose, patch in edit.
async function flush ( ) : Promise < void > {
if ( saving . value ) return ;
if ( isCreate . value ) {
if ( ! hasContent . value ) return ;
saving . value = true ;
try {
await createFromFields ( ) ;
} finally {
saving . value = false ;
}
return ;
}
const b = baseline . value ;
const nextBody = showChecklist . value ? b . body : body . value ;
const changed = ( title . value . trim ( ) || null ) !== b . title || nextBody !== b . body || color . value !== b . color ;
if ( ! changed ) return ;
saving . value = true ;
try {
const res = await api . get < { backlinks : { id : string ; title : string } [ ] } > (
` /api/notes/ ${ props . note . id } /backlinks ` ,
) ;
await notes . saveEdit ( noteId . value as string , { title : title . value , body : nextBody , color : color . value } ) ;
baseline . value = { title : title . value . trim ( ) || null , body : nextBody , color : color . value } ;
} finally {
saving . value = false ;
}
}
function resetCompose ( ) : void {
noteId . value = null ;
title . value = "" ;
body . value = "" ;
color . value = "default" ;
labelList . value = [ ] ;
createKind . value = "text" ;
baseline . value = { title : null , body : "" , color : "default" } ;
linkMenu . value = false ;
uploadError . value = "" ;
}
// ---- inline (compose) frame ----
async function open ( ) : Promise < void > {
expanded . value = true ;
await nextTick ( ) ;
bodyInput . value ? . focus ( ) ;
autoGrow ( ) ;
}
async function commitInline ( ) : Promise < void > {
await flush ( ) ;
resetCompose ( ) ;
expanded . value = false ;
}
async function commitAndContinue ( ) : Promise < void > {
if ( isCreate . value && ! hasContent . value ) return ;
await flush ( ) ;
resetCompose ( ) ;
await nextTick ( ) ;
bodyInput . value ? . focus ( ) ;
autoGrow ( ) ;
}
// ---- modal (edit) frame ----
async function close ( ) : Promise < void > {
await flush ( ) ;
emit ( "close" ) ;
}
function onEsc ( ) : void {
if ( props . inline ) void commitInline ( ) ;
else void close ( ) ;
}
function onMetaEnter ( ) : void {
if ( ! props . inline ) void close ( ) ;
}
function onBackdropMousedown ( ) : void {
if ( ! props . inline ) void close ( ) ;
}
function onDocMousedown ( e : MouseEvent ) : void {
if ( props . inline && expanded . value && root . value && ! root . value . contains ( e . target as Node ) ) {
void commitInline ( ) ;
}
}
// Grow the composer textarea to fit its content (inline only; modal uses fixed rows).
function autoGrow ( ) : void {
if ( ! props . inline ) return ;
const el = bodyInput . value ;
if ( ! el ) return ;
el . style . height = "auto" ;
el . style . height = ` ${ el . scrollHeight } px ` ;
}
async function loadBacklinks ( ) : Promise < void > {
if ( ! noteId . value ) {
backlinks . value = [ ] ;
return ;
}
try {
const res = await api . get < { backlinks : { id : string ; title : string } [ ] } > ( ` /api/notes/ ${ noteId . value } /backlinks ` ) ;
backlinks . value = res . backlinks ;
} catch {
backlinks . value = [ ] ;
}
}
watch ( ( ) => noteId . value , loadBacklinks ) ;
onMounted ( async ( ) => {
void titles . load ( ) ;
void loadBacklinks ( ) ;
await nextTick ( ) ;
bodyInput . value ? . focus ( ) ;
if ( props . inline ) {
document . addEventListener ( "mousedown" , onDocMousedown ) ;
if ( props . autofocus ) void open ( ) ;
} else {
await nextTick ( ) ;
bodyInput . value ? . focus ( ) ;
}
} ) ;
onBeforeUnmount ( ( ) => document . removeEventListener ( "mousedown" , onDocMousedown ) ) ;
watch (
( ) => props . note . id ,
( ) => void loadBacklinks ( ) ,
) ;
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed ( ( ) => {
const re = /\[\[([^[\]]+)\]\]/g ;
const seen = new Set < string > ( ) ;
const out : { title : string ; id : string | null } [ ] = [ ] ;
let match : RegExpExecArray | null ;
while ( ( match = re . exec ( body . value ) ) !== null ) {
const title = match [ 1 ] . trim ( ) ;
const key = title . toLowerCase ( ) ;
if ( title && ! seen . has ( key ) ) {
const t = match [ 1 ] . trim ( ) ;
const key = t . toLowerCase ( ) ;
if ( t && ! seen . has ( key ) ) {
seen . add ( key ) ;
out . push ( { title , id : titles . resolve ( title ) ? . id ? ? null } ) ;
out . push ( { title : t , id : titles . resolve ( t ) ? . id ? ? null } ) ;
}
}
return out ;
@@ -87,15 +266,11 @@ async function openLink(link: { title: string; id: string | null }) {
emit ( "navigate" , created . id ) ;
}
// --- [[ link autocomplete in the body textarea ---
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref ( false ) ;
const linkQuery = ref ( "" ) ;
const linkStart = ref ( - 1 ) ; // index of the `[[` that opened the current token
const linkStart = ref ( - 1 ) ;
const linkSelected = ref ( 0 ) ;
// [[ autocomplete searches note NAME *and* body via the server (link-search), so you
// can link by recalling any phrase — not just the exact name. Debounced so we don't
// fire a request on every keystroke.
const linkMatches = ref < TitleEntry [ ] > ( [ ] ) ;
let linkTimer : ReturnType < typeof setTimeout > | undefined ;
@@ -104,11 +279,8 @@ function refreshLinkMatches() {
const q = linkQuery . value . trim ( ) ;
linkTimer = setTimeout ( async ( ) => {
try {
const res = await api . get < { results : TitleEntry [ ] } > (
` /api/notes/link-search?q= ${ encodeURIComponent ( q ) } ` ,
) ;
// Never suggest linking a note to itself.
linkMatches . value = res . results . filter ( ( r ) => r . id !== props . note . id ) . slice ( 0 , 8 ) ;
const res = await api . get < { results : TitleEntry [ ] } > ( ` /api/notes/link-search?q= ${ encodeURIComponent ( q ) } ` ) ;
linkMatches . value = res . results . filter ( ( r ) => r . id !== noteId . value ) . slice ( 0 , 8 ) ;
} catch {
linkMatches . value = [ ] ;
}
@@ -116,9 +288,8 @@ function refreshLinkMatches() {
} , 120 ) ;
}
// On every edit, check whether the caret sits inside an unclosed `[[…` token
// and, if so, open the suggestion menu with the partial title as the query.
function onBodyInput ( ) {
if ( props . inline ) autoGrow ( ) ;
const el = bodyInput . value ;
if ( ! el ) return ;
const caret = el . selectionStart ? ? 0 ;
@@ -140,12 +311,12 @@ function onBodyInput() {
refreshLinkMatches ( ) ;
}
function insertLink ( title : string ) {
function insertLink ( t : string ) {
const el = bodyInput . value ;
const caret = el ? . selectionStart ? ? body . value . length ;
const before = body . value . slice ( 0 , linkStart . value ) ;
const after = body . value . slice ( caret ) ;
const insertion = ` [[ ${ title } ]] ` ;
const insertion = ` [[ ${ t } ]] ` ;
body . value = before + insertion + after ;
linkMenu . value = false ;
const pos = before . length + insertion . length ;
@@ -156,6 +327,12 @@ function insertLink(title: string) {
}
function onBodyKeydown ( e : KeyboardEvent ) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if ( props . inline && e . key === "Enter" && e . shiftKey ) {
e . preventDefault ( ) ;
void commitAndContinue ( ) ;
return ;
}
if ( ! linkMenu . value || linkMatches . value . length === 0 ) return ;
if ( e . key === "ArrowDown" ) {
e . preventDefault ( ) ;
@@ -170,20 +347,32 @@ function onBodyKeydown(e: KeyboardEvent) {
insertLink ( m . title ) ;
}
} else if ( e . key === "Escape" ) {
// Close only the menu — don't let it bubble to the dialog's Esc-to-close .
// Close only the menu — don't let Esc bubble to the frame's close/commit .
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
linkMenu . value = false ;
}
}
const reminderLocal = computed ( ( ) => toLocalInput ( liveNote . value . remind _at ) ) ;
function onReminderChange ( e : Event ) {
void notes . setReminder ( props . note . id , fromLocalInput ( ( e . target as HTMLInputElement ) . value ) ) ;
function onTitleEnter ( e : KeyboardEvent ) {
if ( ! props . inline ) return ;
e . preventDefault ( ) ;
if ( e . shiftKey ) void commitAndContinue ( ) ;
else bodyInput . value ? . focus ( ) ;
}
// ---- reminder ----
const reminderLocal = computed ( ( ) => toLocalInput ( liveNote . value . remind _at ) ) ;
async function onReminderChange ( e : Event ) {
const id = await ensureDraft ( ) ;
if ( ! id ) return ;
await notes . setReminder ( id , fromLocalInput ( ( e . target as HTMLInputElement ) . value ) ) ;
}
// ---- labels ----
async function onLabelsChange ( next : NoteLabel [ ] ) {
const id = await ensureDraft ( ) ;
if ( ! id ) return ;
// Tag-sourced labels are governed by the note body, not the picker — always keep
// them so a picker save can't strip a label the #tag still mandates.
const tagLabels = labelList . value . filter ( ( lb ) => lb . via _tag ) ;
@@ -191,60 +380,63 @@ async function onLabelsChange(next: NoteLabel[]) {
const merged = [ ... next . filter ( ( lb ) => ! tagIds . has ( lb . id ) ) , ... tagLabels ] ;
labelList . value = merged ;
await notes . setLabels (
props . note . id ,
id ,
merged . map ( ( lb ) => lb . id ) ,
) ;
}
async function removeLabel ( id : string ) {
await onLabelsChange ( labelList . value . filter ( ( lb ) => lb . id !== id ) ) ;
}
function labelChip ( color : string ) : string {
return LABEL _CHIP _CLASSES [ color as NoteColor ] ? ? LABEL _CHIP _CLASSES . default ;
function labelChip ( c : string ) : string {
return LABEL _CHIP _CLASSES [ c as NoteColor ] ? ? LABEL _CHIP _CLASSES . default ;
}
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
async function toggleKind ( ) {
if ( isCreate . value ) {
createKind . value = createKind . value === "list" ? "text" : "list" ;
bodyInput . value ? . focus ( ) ;
void nextTick ( autoGrow ) ;
return ;
}
const id = noteId . value as string ;
if ( liveNote . value . kind === "list" ) {
await notes . setKind ( props . note . id , "text" ) ;
await notes . setKind ( id , "text" ) ;
return ;
}
// Convert existing body lines into checklist items, then switch to a list.
const lines = body . value
. split ( "\n" )
. map ( ( s ) => s . trim ( ) )
. filter ( ( s ) => s . length > 0 ) ;
for ( const line of lines ) await notes . addItem ( props . note . id , line ) ;
for ( const line of lines ) await notes . addItem ( id , line ) ;
if ( lines . length > 0 ) {
body . value = "" ;
await notes . saveEdit ( props . note . id , { title : title . value , body : "" , color : color . value } ) ;
await notes . saveEdit ( id , { title : title . value , body : "" , color : color . value } ) ;
baseline . value = { title : title . value . trim ( ) || null , body : "" , color : color . value } ;
}
await notes . setKind ( props . note . id , "list" ) ;
await notes . setKind ( id , "list" ) ;
}
const fileInput = ref < HTMLInputElement | null > ( null ) ;
const uploadError = ref ( "" ) ;
// ---- attachments ----
function pickImage ( ) {
fileInput . value ? . click ( ) ;
}
async function uploadFile ( file : File ) {
const id = await ensureDraft ( ) ;
if ( ! id ) return ;
uploadError . value = "" ;
try {
await notes . uploadAttachment ( props . note . id , file ) ;
await notes . uploadAttachment ( id , file ) ;
} catch ( e ) {
uploadError . value = ( e as { error ? : string } ) . error ? ? "Could not upload image." ;
}
}
async function onFileChange ( e : Event ) {
const input = e . target as HTMLInputElement ;
const file = input . files ? . [ 0 ] ;
if ( file ) await uploadFile ( file ) ;
input . value = "" ;
}
async function onPaste ( e : ClipboardEvent ) {
const item = Array . from ( e . clipboardData ? . items ? ? [ ] ) . find ( ( i ) => i . type . startsWith ( "image/" ) ) ;
const file = item ? . getAsFile ( ) ;
@@ -254,35 +446,52 @@ async function onPaste(e: ClipboardEvent) {
}
}
async function close ( ) {
const changed =
( title . value . trim ( ) || null ) !== ( props . note . title ? ? null ) ||
body . value !== props . note . body ||
color . value !== props . note . color ;
if ( changed ) {
await notes . saveEdit ( props . note . id , { title : title . value , body : body . value , color : color . value } ) ;
}
emit ( "close" ) ;
}
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
async function act ( fn : ( ) => Promise < void > ) {
await fn ( ) ;
emit ( "close" ) ;
}
defineExpose ( { open } ) ;
< / script >
< template >
< div
class = "fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh] "
@mousedown.self ="close"
ref = "root "
: class = "
inline
? 'mx-auto w-full max-w-xl'
: 'fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]'
"
@mousedown.self ="onBackdropMousedown"
>
<!-- Collapsed composer ( inline , board ) -- >
< div
class = "w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
role = "dialog "
aria -modal = " true "
@keydown.esc ="close"
@keydown.enter.meta.prevent ="close"
@keydown.enter.ctrl.prevent ="close"
v-if = "inline && !expanded"
class = "rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900 "
>
< button
type = "button"
class = "w-full rounded-xl px-4 py-3 text-left text-sm text-neutral-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400"
@click ="open"
>
Take a note …
< / button >
< / div >
<!-- Shared editor card : compose ( expanded , inline ) OR edit ( modal ) -- >
< div
v-else
: class = "
inline
? 'w-full rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-700 dark:bg-neutral-900'
: 'w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900'
"
: role = "inline ? undefined : 'dialog'"
: aria -modal = " inline ? undefined : ' true ' "
@keydown.esc ="onEsc"
@keydown.enter.meta.prevent ="onMetaEnter"
@keydown.enter.ctrl.prevent ="onMetaEnter"
@paste ="onPaste"
>
< div class = "flex flex-col gap-2 p-4" >
@@ -293,26 +502,33 @@ async function act(fn: () => Promise<void>) {
type = "button"
class = "absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria -label = " Remove image "
@click ="notes.deleteAttachment(n ote.id, att.id)"
@click ="notes.deleteAttachment(liveN ote.id, att.id)"
>
×
< / button >
< / div >
< / div >
< p v-if = "uploadError" class="text-xs text-red-600 dark:text-red-400" > {{ uploadError }} < / p >
< input
v-model = "title"
type = "text"
placeholder = "Title (optional)"
class = "w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter ="onTitleEnter"
/ >
< div v-if = "liveNote.kind === 'text'" class="relative" >
< div v-if = "!showChecklist" class="relative" >
< textarea
ref = "bodyInput"
v-model = "body"
rows = "8"
placeholder = "Take a note… ([[ to link a note) "
class = "w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
: rows = "inline ? undefined : 8"
:placeholder = "bodyPlaceholder "
: class = "
inline
? 'max-h-64 min-h-[4.5rem] w-full resize-none overflow-y-auto bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
: 'w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400'
"
@input ="onBodyInput"
@keydown ="onBodyKeydown"
/ >
@@ -359,26 +575,26 @@ async function act(fn: () => Promise<void>) {
< / span >
< / div >
< div class = "flex items-center gap-2 pt-1" >
< div v-if = "richEnabled" class=" flex items-center gap-2 pt-1" >
< Icon name = "bell" class = "text-neutral-400" / >
< input
type = "datetime-local"
:value = "reminderLocal"
class = "rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
class = "rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:[color-scheme:dark] "
@change ="onReminderChange"
/ >
< button
v-if = "liveNote.remind_at"
type = "button"
class = "text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
@click ="notes.setReminder(n ote.id, null)"
@click ="notes.setReminder(liveN ote.id, null)"
>
Clear
< / button >
< / div >
< div
v-if = "outgoingLinks.length || backlinks.length"
v-if = "!inline && !isCreate && ( outgoingLinks.length || backlinks.length) "
class = "flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
< div v-if = "outgoingLinks.length" >
@@ -422,7 +638,7 @@ async function act(fn: () => Promise<void>) {
< ColorPicker v-model = "color" / >
< div class = "flex items-center gap-0.5" >
< button
v-if = "!n ote.trashed"
v-if = "richEnabled && !liveN ote.trashed"
type = "button"
class = "icon-btn"
title = "Add image"
@@ -439,57 +655,68 @@ async function act(fn: () => Promise<void>) {
@change ="onFileChange"
/ >
< button
v-if = "!n ote.trashed"
v-if = "!liveN ote.trashed"
type = "button"
class = "icon-btn"
: class = "liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
: title = "liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
: class = "isListMode ? 'text-brand-700 dark:text-brand' : ''"
: title = "isListMode ? 'Switch to a note' : 'Make a checklist'"
:aria-pressed = "isListMode"
@click ="toggleKind"
>
< Icon name = "checkbox" / >
< / button >
< LabelPicker v-if = "!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" / >
< template v-if = "!n ote.trashed" >
< LabelPicker
v-if = "richEnabled && !liveN ote.trashed"
:model-value = "labelList"
@ update :model-value = "onLabelsChange"
/ >
< template v-if = "!inline && !isCreate && !liveNote.trashed" >
< button
type = "button"
class = "icon-btn"
: class = "n ote.pinned ? 'text-brand-700 dark:text-brand' : ''"
: title = "n ote.pinned ? 'Unpin' : 'Pin'"
@click ="act(() => notes.setPinned(n ote.id, !n ote.pinned))"
: class = "liveN ote.pinned ? 'text-brand-700 dark:text-brand' : ''"
: title = "liveN ote.pinned ? 'Unpin' : 'Pin'"
@click ="act(() => notes.setPinned(liveN ote.id, !liveN ote.pinned))"
>
< Icon name = "pin" / >
< / button >
< button
type = "button"
class = "icon-btn"
: title = "n ote.archived ? 'Unarchive' : 'Archive'"
@click ="act(() => notes.setArchived(n ote.id, !n ote.archived))"
: title = "liveN ote.archived ? 'Unarchive' : 'Archive'"
@click ="act(() => notes.setArchived(liveN ote.id, !liveN ote.archived))"
>
< Icon name = "archive" / >
< / button >
< button type = "button" class = "icon-btn" title = "Move to trash" @click ="act(() => notes.trash(note.id))" >
< button
type = "button"
class = "icon-btn"
title = "Move to trash"
@click ="act(() => notes.trash(liveNote.id))"
>
< Icon name = "trash" / >
< / button >
< / template >
< template v-else >
< button type = "button" class = "icon-btn" title = "Restore" @click ="act(() => notes.restore(n ote.id))" >
< template v-else-if = "!inline && !isCreate && liveNote.trashed" >
< button type = "button" class = "icon-btn" title = "Restore" @click ="act(() => notes.restore(liveN ote.id))" >
< Icon name = "restore" / >
< / button >
< button
type = "button"
class = "icon-btn"
title = "Delete forever"
@click ="act(() => notes.deleteForever(n ote.id))"
@click ="act(() => notes.deleteForever(liveN ote.id))"
>
< Icon name = "trash" / >
< / button >
< / template >
< button
type = "button"
class = "rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-200 dark:hover:bg-neutral-800"
@click ="close"
class = "rounded-md px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled = "saving"
@click ="inline ? commitInline() : close()"
>
Close
{ { inline ? "Done" : "Close" } }
< / button >
< / div >
< / div >