tabSize: 4

This commit is contained in:
2023-12-09 11:29:00 +01:00
parent 10c0387eb0
commit 2880dd7a03
54 changed files with 2572 additions and 2481 deletions

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false, "semi": false,
"tabWidth": 2, "tabWidth": 4,
"singleQuote": true, "singleQuote": true,
"printWidth": 100, "printWidth": 100,
"trailingComma": "none", "trailingComma": "none",

View File

@@ -2,11 +2,11 @@
import { activeNote, updateNote, notes, activeNotesSource, getNotes } from '@/composables/useNotes' import { activeNote, updateNote, notes, activeNotesSource, getNotes } from '@/composables/useNotes'
import { viewModes, activeViewMode } from '@/composables/useViewMode' import { viewModes, activeViewMode } from '@/composables/useViewMode'
import { import {
getClientKey, getClientKey,
getEncryptionKey, getEncryptionKey,
encryptionKey, encryptionKey,
setClientKey, setClientKey,
passphraseRequired passphraseRequired
} from '@/composables/useEncryption' } from '@/composables/useEncryption'
import { initializeSettings } from '@/composables/useSettings' import { initializeSettings } from '@/composables/useSettings'
import { windowIsMobile } from '@/utils/helpers' import { windowIsMobile } from '@/utils/helpers'
@@ -32,28 +32,28 @@ watch(width, () => (sideBarCollapsed.value = windowIsMobile()))
// provide('firebaseAuthUI', firebaseAuthUI) // provide('firebaseAuthUI', firebaseAuthUI)
watch( watch(
[activeNotesSource, encryptionKey], [activeNotesSource, encryptionKey],
() => { () => {
if (activeNotesSource.value === 'firebase') { if (activeNotesSource.value === 'firebase') {
getClientKey() getClientKey()
getEncryptionKey() getEncryptionKey()
} }
getNotes() getNotes()
}, },
{ immediate: true } { immediate: true }
) )
const passphrase = ref('') const passphrase = ref('')
const passphraseValid = ref<boolean>() const passphraseValid = ref<boolean>()
const submitPassphrase = (close: () => void) => { const submitPassphrase = (close: () => void) => {
const setClientKeyResult = setClientKey(passphrase.value) const setClientKeyResult = setClientKey(passphrase.value)
passphraseValid.value = setClientKeyResult passphraseValid.value = setClientKeyResult
if (passphraseValid.value) close() if (passphraseValid.value) close()
} }
const loading = computed( const loading = computed(
() => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value () => notes.value.length === 0 || passphraseRequired.value || !activeNotesSource.value
) )
provide('loading', loading) provide('loading', loading)
@@ -62,90 +62,90 @@ const topBarHeightWithSafeArea = computed(() => `calc(${topBarHeight}px + var(--
</script> </script>
<template> <template>
<TopBar <TopBar
:side-bar-collapsed="sideBarCollapsed" :side-bar-collapsed="sideBarCollapsed"
:height="topBarHeight" :height="topBarHeight"
:style="{ height: topBarHeightWithSafeArea }" :style="{ height: topBarHeightWithSafeArea }"
@toggle-side-bar="sideBarCollapsed = !sideBarCollapsed" @toggle-side-bar="sideBarCollapsed = !sideBarCollapsed"
class="pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]" class="pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]"
/> />
<!-- <div class="absolute bottom-0 left-0 right-0 top-[50px] mx-auto flex flex-grow"> --> <!-- <div class="absolute bottom-0 left-0 right-0 top-[50px] mx-auto flex flex-grow"> -->
<div <div
class="mx-auto flex w-full max-w-app flex-grow pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]" class="mx-auto flex w-full max-w-app flex-grow pe-[var(--safe-area-right)] ps-[var(--safe-area-left)]"
>
<Transition name="sidebar">
<SideBar
:view-modes="viewModes"
:active-view-mode="activeViewMode"
@set-view-mode="(viewMode) => (activeViewMode = viewMode)"
@collapse="(collapse) => (sideBarCollapsed = collapse)"
class="bg-gray-100 px-3 py-6 transition-[width] delay-200 duration-0 max-sm:z-50 max-sm:border-x-[1px] max-sm:py-3 max-sm:transition-transform max-sm:delay-0 max-sm:duration-200"
:style="{ 'margin-top': topBarHeightWithSafeArea }"
v-if="!sideBarCollapsed"
/>
</Transition>
<Transition name="overlay">
<div
class="absolute bottom-0 left-0 right-0 top-0 z-40 cursor-pointer bg-neutral-800 bg-opacity-60 transition-opacity duration-200 sm:hidden"
@click="sideBarCollapsed = true"
v-if="!sideBarCollapsed"
/>
</Transition>
<main
class="transition[margin-left] z-10 mx-auto flex h-full w-full max-w-app flex-col overflow-y-auto bg-white pb-[var(--safe-area-bottom)] duration-200 ease-out sm:border-x-[1px]"
:class="sideBarCollapsed ? 'ml-0' : 'sm:ml-sidebar'"
> >
<div class="flex w-full flex-grow px-10 py-6 max-sm:px-4 max-sm:py-3"> <Transition name="sidebar">
<template v-if="!loading"> <SideBar
<Note :view-modes="viewModes"
v-if="activeViewMode.name === 'Note' && activeNote" :active-view-mode="activeViewMode"
:key="activeNote.id" @set-view-mode="(viewMode) => (activeViewMode = viewMode)"
:note="activeNote" @collapse="(collapse) => (sideBarCollapsed = collapse)"
class="" class="bg-gray-100 px-3 py-6 transition-[width] delay-200 duration-0 max-sm:z-50 max-sm:border-x-[1px] max-sm:py-3 max-sm:transition-transform max-sm:delay-0 max-sm:duration-200"
@update="(note) => updateNote(note.id, note)" :style="{ 'margin-top': topBarHeightWithSafeArea }"
/> v-if="!sideBarCollapsed"
<ListView v-else-if="activeViewMode.name === 'List'" /> />
<Mindmap v-else-if="activeViewMode.name === 'Mindmap'" /> </Transition>
<Transition name="overlay">
<div
class="absolute bottom-0 left-0 right-0 top-0 z-40 cursor-pointer bg-neutral-800 bg-opacity-60 transition-opacity duration-200 sm:hidden"
@click="sideBarCollapsed = true"
v-if="!sideBarCollapsed"
/>
</Transition>
<main
class="transition[margin-left] z-10 mx-auto flex h-full w-full max-w-app flex-col overflow-y-auto bg-white pb-[var(--safe-area-bottom)] duration-200 ease-out sm:border-x-[1px]"
:class="sideBarCollapsed ? 'ml-0' : 'sm:ml-sidebar'"
>
<div class="flex w-full flex-grow px-10 py-6 max-sm:px-4 max-sm:py-3">
<template v-if="!loading">
<Note
v-if="activeViewMode.name === 'Note' && activeNote"
:key="activeNote.id"
:note="activeNote"
class=""
@update="(note) => updateNote(note.id, note)"
/>
<ListView v-else-if="activeViewMode.name === 'List'" />
<Mindmap v-else-if="activeViewMode.name === 'Mindmap'" />
</template>
<SkeletonNote v-else />
</div>
</main>
</div>
<UIModal :open="passphraseRequired" persistent>
<template #title>Enter your passphrase</template>
<template #default="{ close }">
<div>
Your notes are encrypted. Please enter your encryption key passphrase to decrypt
your cloud notes.
</div>
<form @submit.prevent="submitPassphrase(close)">
<UIInputText
type="password"
class="w-full !max-w-full"
:color="passphraseValid === false ? 'error' : 'regular'"
v-model="passphrase"
></UIInputText>
</form>
<UIAlert color="error" class="mt-4" v-if="passphraseValid === false">
<i class="fa-solid fa-triangle-exclamation"></i>
The passphrase you entered is incorrect.
</UIAlert>
</template> </template>
<SkeletonNote v-else /> <template #actions="{ close }">
</div> <UIButton color="primary" size="sm" @click="submitPassphrase(close)">Submit</UIButton>
</main> </template>
</div> </UIModal>
<UIModal :open="passphraseRequired" persistent>
<template #title>Enter your passphrase</template>
<template #default="{ close }">
<div>
Your notes are encrypted. Please enter your encryption key passphrase to decrypt your cloud
notes.
</div>
<form @submit.prevent="submitPassphrase(close)">
<UIInputText
type="password"
class="w-full !max-w-full"
:color="passphraseValid === false ? 'error' : 'regular'"
v-model="passphrase"
></UIInputText>
</form>
<UIAlert color="error" class="mt-4" v-if="passphraseValid === false">
<i class="fa-solid fa-triangle-exclamation"></i>
The passphrase you entered is incorrect.
</UIAlert>
</template>
<template #actions="{ close }">
<UIButton color="primary" size="sm" @click="submitPassphrase(close)">Submit</UIButton>
</template>
</UIModal>
</template> </template>
<style scoped> <style scoped>
.sidebar-enter-from, .sidebar-enter-from,
.sidebar-leave-to { .sidebar-leave-to {
@apply max-sm:-translate-x-full; @apply max-sm:-translate-x-full;
} }
.overlay-enter-from, .overlay-enter-from,
.overlay-leave-to { .overlay-leave-to {
@apply opacity-0; @apply opacity-0;
} }
main { main {
contain: size layout style; contain: size layout style;
} }
</style> </style>

View File

@@ -15,226 +15,228 @@ const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'
const INPUT_EVENT_DEBOUNCE_WAIT = 300 const INPUT_EVENT_DEBOUNCE_WAIT = 300
export interface CKEditorComponentData { export interface CKEditorComponentData {
instance: Editor | null instance: Editor | null
lastEditorData: string | null lastEditorData: string | null
} }
export default defineComponent({ export default defineComponent({
name: 'Ckeditor', name: 'Ckeditor',
model: { model: {
prop: 'modelValue', prop: 'modelValue',
event: 'update:modelValue' event: 'update:modelValue'
},
props: {
editor: {
type: Function as unknown as PropType<{
create(...args: any): Promise<Editor>
}>,
required: true
},
config: {
type: Object as PropType<EditorConfig>,
default: () => ({})
},
modelValue: {
type: String,
default: ''
},
tagName: {
type: String,
default: 'div'
},
disabled: {
type: Boolean,
default: false
},
disableTwoWayDataBinding: {
type: Boolean,
default: false
}
},
emits: [
'ready',
'destroy',
'blur',
'focus',
'input',
'update:modelValue',
'click',
'editorReady',
'contextedLinkAutocomplete',
'contextedKeypress'
],
data(): CKEditorComponentData {
return {
// Don't define it in #props because it produces a warning.
// https://v3.vuejs.org/guide/component-props.html#one-way-data-flow
instance: null,
lastEditorData: null
}
},
watch: {
modelValue(value) {
// Synchronize changes of #modelValue. There are two sources of changes:
//
// External modelValue change ──────╮
// ╰─────> ┏━━━━━━━━━━━┓
// ┃ Component ┃
// ╭─────> ┗━━━━━━━━━━━┛
// Internal data change ──────╯
// (typing, commands, collaboration)
//
// Case 1: If the change was external (via props), the editor data must be synced with
// the component using instance#setData() and it is OK to destroy the selection.
//
// Case 2: If the change is the result of internal data change, the #modelValue is the
// same as this.lastEditorData, which has been cached on #change:data. If we called
// instance#setData() at this point, that would demolish the selection.
//
// To limit the number of instance#setData() which is time-consuming when there is a
// lot of data we make sure:
// * the new modelValue is at least different than the old modelValue (Case 1.)
// * the new modelValue is different than the last internal instance state (Case 2.)
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42.
if (this.instance && value !== this.lastEditorData) {
this.instance.data.set(value)
}
}, },
// Synchronize changes of #disabled. props: {
disabled(readOnlyMode) { editor: {
if (readOnlyMode) { type: Function as unknown as PropType<{
this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID) create(...args: any): Promise<Editor>
} else { }>,
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID) required: true
}
}
},
created() {
const { CKEDITOR_VERSION } = window
if (CKEDITOR_VERSION) {
const [major] = CKEDITOR_VERSION.split('.').map(Number)
if (major < 37) {
console.warn('The <CKEditor> component requires using CKEditor 5 in version 37 or higher.')
}
} else {
console.warn('Cannot find the "CKEDITOR_VERSION" in the "window" scope.')
}
},
mounted() {
// Clone the config first so it never gets mutated (across multiple editor instances).
// https://github.com/ckeditor/ckeditor5-vue/issues/101
const editorConfig: EditorConfig = Object.assign({}, this.config)
if (this.modelValue) {
editorConfig.initialData = this.modelValue
}
this.editor
.create(this.$el, editorConfig)
.then((editor) => {
// Save the reference to the instance for further use.
this.instance = markRaw(editor)
this.setUpEditorEvents()
// Synchronize the editor content. The #modelValue may change while the editor is being created, so the editor content has
// to be synchronized with these potential changes as soon as it is ready.
if (this.modelValue !== editorConfig.initialData) {
editor.data.set(this.modelValue)
}
// Set initial disabled state.
if (this.disabled) {
editor.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
// Let the world know the editor is ready.
this.$emit('ready', editor)
})
.catch((error) => {
console.error(error)
})
},
beforeUnmount() {
if (this.instance) {
this.instance.destroy()
this.instance = null
}
// Note: By the time the editor is destroyed (promise resolved, editor#destroy fired)
// the Vue component will not be able to emit any longer. So emitting #destroy a bit earlier.
this.$emit('destroy', this.instance)
},
methods: {
setUpEditorEvents() {
const editor = this.instance!
this.$emit('editorReady', editor)
// Use the leading edge so the first event in the series is emitted immediately.
// Failing to do so leads to race conditions, for instance, when the component modelValue
// is set twice in a time span shorter than the debounce time.
// See https://github.com/ckeditor/ckeditor5-vue/issues/149.
const emitDebouncedInputEvent = debounce(
(evt) => {
if (this.disableTwoWayDataBinding) {
return
}
// Cache the last editor data. This kind of data is a result of typing,
// editor command execution, collaborative changes to the document, etc.
// This data is compared when the component modelValue changes in a 2-way binding.
const data = (this.lastEditorData = editor.data.get())
// The compatibility with the v-model and general Vue.js concept of inputlike components.
this.$emit('update:modelValue', data, evt, editor)
this.$emit('input', data, evt, editor)
}, },
INPUT_EVENT_DEBOUNCE_WAIT, config: {
{ leading: true } type: Object as PropType<EditorConfig>,
) default: () => ({})
},
modelValue: {
type: String,
default: ''
},
tagName: {
type: String,
default: 'div'
},
disabled: {
type: Boolean,
default: false
},
disableTwoWayDataBinding: {
type: Boolean,
default: false
}
},
// Debounce emitting the #input event. When data is huge, instance#getData() emits: [
// takes a lot of time to execute on every single key press and ruins the UX. 'ready',
// 'destroy',
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42 'blur',
editor.model.document.on('change:data', emitDebouncedInputEvent) 'focus',
'input',
'update:modelValue',
'click',
'editorReady',
'contextedLinkAutocomplete',
'contextedKeypress'
],
editor.editing.view.document.on('focus', (evt) => { data(): CKEditorComponentData {
this.$emit('focus', evt, editor) return {
}) // Don't define it in #props because it produces a warning.
// https://v3.vuejs.org/guide/component-props.html#one-way-data-flow
instance: null,
lastEditorData: null
}
},
editor.editing.view.document.on('blur', (evt) => { watch: {
this.$emit('blur', evt, editor) modelValue(value) {
}) // Synchronize changes of #modelValue. There are two sources of changes:
//
// External modelValue change ──────╮
// ╰─────> ┏━━━━━━━━━━━┓
// ┃ Component ┃
// ╭─────> ┗━━━━━━━━━━━┛
// Internal data change ──────╯
// (typing, commands, collaboration)
//
// Case 1: If the change was external (via props), the editor data must be synced with
// the component using instance#setData() and it is OK to destroy the selection.
//
// Case 2: If the change is the result of internal data change, the #modelValue is the
// same as this.lastEditorData, which has been cached on #change:data. If we called
// instance#setData() at this point, that would demolish the selection.
//
// To limit the number of instance#setData() which is time-consuming when there is a
// lot of data we make sure:
// * the new modelValue is at least different than the old modelValue (Case 1.)
// * the new modelValue is different than the last internal instance state (Case 2.)
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42.
if (this.instance && value !== this.lastEditorData) {
this.instance.data.set(value)
}
},
// Custom event // Synchronize changes of #disabled.
editor.editing.view.document.on('click', (evt, data) => { disabled(readOnlyMode) {
this.$emit('click', { evt, data }, editor) if (readOnlyMode) {
}) this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
} else {
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
}
},
editor.model.document.on('contextedLinkAutocomplete', (_, data) => { created() {
this.$emit('contextedLinkAutocomplete', data) const { CKEDITOR_VERSION } = window
})
editor.model.document.on('contextedKeypress', (_, eventData) => { if (CKEDITOR_VERSION) {
this.$emit('contextedKeypress', eventData) const [major] = CKEDITOR_VERSION.split('.').map(Number)
})
if (major < 37) {
console.warn(
'The <CKEditor> component requires using CKEditor 5 in version 37 or higher.'
)
}
} else {
console.warn('Cannot find the "CKEDITOR_VERSION" in the "window" scope.')
}
},
mounted() {
// Clone the config first so it never gets mutated (across multiple editor instances).
// https://github.com/ckeditor/ckeditor5-vue/issues/101
const editorConfig: EditorConfig = Object.assign({}, this.config)
if (this.modelValue) {
editorConfig.initialData = this.modelValue
}
this.editor
.create(this.$el, editorConfig)
.then((editor) => {
// Save the reference to the instance for further use.
this.instance = markRaw(editor)
this.setUpEditorEvents()
// Synchronize the editor content. The #modelValue may change while the editor is being created, so the editor content has
// to be synchronized with these potential changes as soon as it is ready.
if (this.modelValue !== editorConfig.initialData) {
editor.data.set(this.modelValue)
}
// Set initial disabled state.
if (this.disabled) {
editor.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
}
// Let the world know the editor is ready.
this.$emit('ready', editor)
})
.catch((error) => {
console.error(error)
})
},
beforeUnmount() {
if (this.instance) {
this.instance.destroy()
this.instance = null
}
// Note: By the time the editor is destroyed (promise resolved, editor#destroy fired)
// the Vue component will not be able to emit any longer. So emitting #destroy a bit earlier.
this.$emit('destroy', this.instance)
},
methods: {
setUpEditorEvents() {
const editor = this.instance!
this.$emit('editorReady', editor)
// Use the leading edge so the first event in the series is emitted immediately.
// Failing to do so leads to race conditions, for instance, when the component modelValue
// is set twice in a time span shorter than the debounce time.
// See https://github.com/ckeditor/ckeditor5-vue/issues/149.
const emitDebouncedInputEvent = debounce(
(evt) => {
if (this.disableTwoWayDataBinding) {
return
}
// Cache the last editor data. This kind of data is a result of typing,
// editor command execution, collaborative changes to the document, etc.
// This data is compared when the component modelValue changes in a 2-way binding.
const data = (this.lastEditorData = editor.data.get())
// The compatibility with the v-model and general Vue.js concept of inputlike components.
this.$emit('update:modelValue', data, evt, editor)
this.$emit('input', data, evt, editor)
},
INPUT_EVENT_DEBOUNCE_WAIT,
{ leading: true }
)
// Debounce emitting the #input event. When data is huge, instance#getData()
// takes a lot of time to execute on every single key press and ruins the UX.
//
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42
editor.model.document.on('change:data', emitDebouncedInputEvent)
editor.editing.view.document.on('focus', (evt) => {
this.$emit('focus', evt, editor)
})
editor.editing.view.document.on('blur', (evt) => {
this.$emit('blur', evt, editor)
})
// Custom event
editor.editing.view.document.on('click', (evt, data) => {
this.$emit('click', { evt, data }, editor)
})
editor.model.document.on('contextedLinkAutocomplete', (_, data) => {
this.$emit('contextedLinkAutocomplete', data)
})
editor.model.document.on('contextedKeypress', (_, eventData) => {
this.$emit('contextedKeypress', eventData)
})
}
},
render() {
return h(this.tagName)
} }
},
render() {
return h(this.tagName)
}
}) })

View File

@@ -6,178 +6,184 @@ import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'
const HIGHLIGHT_CLASS = 'ck-link_selected' const HIGHLIGHT_CLASS = 'ck-link_selected'
export default class ContextedLinkEditing extends Plugin { export default class ContextedLinkEditing extends Plugin {
init() { init() {
this._defineSchema() // ADDED this._defineSchema() // ADDED
this._defineConverters() // ADDED this._defineConverters() // ADDED
this._addContextedKeyHandler() this._addContextedKeyHandler()
const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement) const twoStepCaretMovementPlugin = this.editor.plugins.get(TwoStepCaretMovement)
twoStepCaretMovementPlugin.registerAttribute('contextedLink') twoStepCaretMovementPlugin.registerAttribute('contextedLink')
inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS) inlineHighlight(this.editor, 'contextedLink', 'a', HIGHLIGHT_CLASS)
this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete')) this.editor.commands.add('autocomplete', new AttributeCommand(this.editor, 'autocomplete'))
} }
afterInit() { afterInit() {
this._addAutocomplete() this._addAutocomplete()
} }
_defineSchema() { _defineSchema() {
// ADDED // ADDED
const schema = this.editor.model.schema const schema = this.editor.model.schema
// Extend the text node's schema to accept the abbreviation attribute. // Extend the text node's schema to accept the abbreviation attribute.
schema.extend('$text', { schema.extend('$text', {
allowAttributes: ['contextedLink', 'autocomplete'] allowAttributes: ['contextedLink', 'autocomplete']
})
}
_defineConverters() {
// ADDED
const conversion = this.editor.conversion
// Conversion from a model attribute to a view element.
conversion.for('downcast').attributeToElement({
model: 'contextedLink',
// Callback function provides access to the model attribute value
// and the DowncastWriter.
view: (modelAttributeValue, conversionApi) => {
const { writer } = conversionApi
return writer.createAttributeElement('a', {
'data-contexted-link': modelAttributeValue
}) })
} }
}) _defineConverters() {
conversion.for('upcast').elementToAttribute({ // ADDED
view: { const conversion = this.editor.conversion
name: 'a',
key: 'data-contexted-link'
},
model: {
key: 'contextedLink'
},
converterPriority: 'high'
})
}
_addAutocomplete() {
// Copied from: node_modules/@ckeditor/ckeditor5-autoformat/src/inlineautoformatediting.js
const editor = this.editor
let showAutocomplete = false
editor.model.document.on('change', (_, batch) => {
if (batch.isUndo || !batch.isLocal) return
const model = editor.model
const selection = model.document.selection
// Do nothing if selection is not collapsed.
if (!selection.isCollapsed) return
const changes = Array.from(model.document.differ.getChanges())
const entry = changes[0]
// Typing is represented by only a single change.
if (
changes.length != 1 ||
(entry.type !== 'insert' && entry.type !== 'remove') ||
(entry.name != '$text' && entry.name != 'paragraph') ||
entry.length != 1
) {
return
}
const focus = selection.focus // Conversion from a model attribute to a view element.
const block = focus?.parent conversion.for('downcast').attributeToElement({
if (!block || !focus) return model: 'contextedLink',
const { text, range } = getTextAfterCode( // Callback function provides access to the model attribute value
model.createRange(model.createPositionAt(block, 0), focus), // and the DowncastWriter.
model view: (modelAttributeValue, conversionApi) => {
) const { writer } = conversionApi
const inputText = (text as string).split(']]').slice(-1)[0]
const autocompleteText = (inputText as string).match(/(?<=\[\[).*/g)
const cursorNodes = [focus.textNode, focus.nodeBefore, focus.nodeAfter]
const autocompleteNode: any = cursorNodes.find((node) =>
['contextedLink', 'autocomplete'].some((attribute) => node?.hasAttribute(attribute))
)
if (Boolean(autocompleteText) !== Boolean(autocompleteNode)) { return writer.createAttributeElement('a', {
editor.execute('autocomplete') 'data-contexted-link': modelAttributeValue
} })
const autocompleteActive = ['contextedLink', 'autocomplete'].some((attribute) => {
return editor.model.document.selection.hasAttribute(attribute)
})
showAutocomplete = autocompleteActive
fireAutocompleteEvent(editor, showAutocomplete, autocompleteNode)
const regexFormat = /(\[\[)([^[]+?)(\]\])$/g
let result
const format: Array<number>[] = []
while ((result = regexFormat.exec(text as string)) !== null) {
if (result && result.length < 4) {
break
}
let index = result.index
const { '1': leftDel, '2': content, '3': rightDel } = result
// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
const found = leftDel + content + rightDel
index += result[0].length - found.length
format.push([index + leftDel.length, index + leftDel.length + content.length])
}
model.enqueueChange((writer) => {
const rangesToFormat = format.map((array) =>
model.createRange(range.start.getShiftedBy(array[0]), range.start.getShiftedBy(array[1]))
)
const validRanges = editor.model.schema.getValidRanges(rangesToFormat, 'contextedLink')
for (const range of validRanges) {
for (const item of range.getItems()) {
if ((item as any).data) {
writer.setAttribute('contextedLink', true, range)
} }
} })
} conversion.for('upcast').elementToAttribute({
}) view: {
}) name: 'a',
} key: 'data-contexted-link'
_addContextedKeyHandler() { },
const editor = this.editor model: {
const viewDocument = editor.editing.view.document key: 'contextedLink'
viewDocument.on( },
'keydown', converterPriority: 'high'
(evt, data) => { })
const { keyCode } = data }
const keyCodesCycle = [38, 40] // Up, Down _addAutocomplete() {
const keyCodesConfirm = [13] // Enter // Copied from: node_modules/@ckeditor/ckeditor5-autoformat/src/inlineautoformatediting.js
const keyCodesCancel = [27] // Escape const editor = this.editor
if (keyCodesCancel.includes(keyCode)) { let showAutocomplete = false
fireAutocompleteEvent(editor, false) editor.model.document.on('change', (_, batch) => {
} if (batch.isUndo || !batch.isLocal) return
const keyCodes = [...keyCodesConfirm, ...keyCodesCycle] const model = editor.model
const selection = editor.model.document.selection const selection = model.document.selection
const selectionInContextedLink = ['contextedLink', 'autocomplete'].some((attribute) => // Do nothing if selection is not collapsed.
selection.hasAttribute(attribute) if (!selection.isCollapsed) return
const changes = Array.from(model.document.differ.getChanges())
const entry = changes[0]
// Typing is represented by only a single change.
if (
changes.length != 1 ||
(entry.type !== 'insert' && entry.type !== 'remove') ||
(entry.name != '$text' && entry.name != 'paragraph') ||
entry.length != 1
) {
return
}
const focus = selection.focus
const block = focus?.parent
if (!block || !focus) return
const { text, range } = getTextAfterCode(
model.createRange(model.createPositionAt(block, 0), focus),
model
)
const inputText = (text as string).split(']]').slice(-1)[0]
const autocompleteText = (inputText as string).match(/(?<=\[\[).*/g)
const cursorNodes = [focus.textNode, focus.nodeBefore, focus.nodeAfter]
const autocompleteNode: any = cursorNodes.find((node) =>
['contextedLink', 'autocomplete'].some((attribute) => node?.hasAttribute(attribute))
)
if (Boolean(autocompleteText) !== Boolean(autocompleteNode)) {
editor.execute('autocomplete')
}
const autocompleteActive = ['contextedLink', 'autocomplete'].some((attribute) => {
return editor.model.document.selection.hasAttribute(attribute)
})
showAutocomplete = autocompleteActive
fireAutocompleteEvent(editor, showAutocomplete, autocompleteNode)
const regexFormat = /(\[\[)([^[]+?)(\]\])$/g
let result
const format: Array<number>[] = []
while ((result = regexFormat.exec(text as string)) !== null) {
if (result && result.length < 4) {
break
}
let index = result.index
const { '1': leftDel, '2': content, '3': rightDel } = result
// Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
const found = leftDel + content + rightDel
index += result[0].length - found.length
format.push([index + leftDel.length, index + leftDel.length + content.length])
}
model.enqueueChange((writer) => {
const rangesToFormat = format.map((array) =>
model.createRange(
range.start.getShiftedBy(array[0]),
range.start.getShiftedBy(array[1])
)
)
const validRanges = editor.model.schema.getValidRanges(
rangesToFormat,
'contextedLink'
)
for (const range of validRanges) {
for (const item of range.getItems()) {
if ((item as any).data) {
writer.setAttribute('contextedLink', true, range)
}
}
}
})
})
}
_addContextedKeyHandler() {
const editor = this.editor
const viewDocument = editor.editing.view.document
viewDocument.on(
'keydown',
(evt, data) => {
const { keyCode } = data
const keyCodesCycle = [38, 40] // Up, Down
const keyCodesConfirm = [13] // Enter
const keyCodesCancel = [27] // Escape
if (keyCodesCancel.includes(keyCode)) {
fireAutocompleteEvent(editor, false)
}
const keyCodes = [...keyCodesConfirm, ...keyCodesCycle]
const selection = editor.model.document.selection
const selectionInContextedLink = ['contextedLink', 'autocomplete'].some(
(attribute) => selection.hasAttribute(attribute)
)
if (selectionInContextedLink && keyCodes.includes(keyCode)) {
if (selection.hasAttribute('contextedLink')) {
const autocompleteNode = [
selection.focus?.nodeBefore,
selection.focus?.textNode,
selection.focus?.nodeAfter
].find((node) => Boolean(node))
fireAutocompleteEvent(editor, true, autocompleteNode)
}
this.editor.model.document.fire('contextedKeypress', { keyCode })
data.preventDefault()
evt.stop()
}
},
{ priority: 'highest' }
) )
if (selectionInContextedLink && keyCodes.includes(keyCode)) { }
if (selection.hasAttribute('contextedLink')) {
const autocompleteNode = [
selection.focus?.nodeBefore,
selection.focus?.textNode,
selection.focus?.nodeAfter
].find((node) => Boolean(node))
fireAutocompleteEvent(editor, true, autocompleteNode)
}
this.editor.model.document.fire('contextedKeypress', { keyCode })
data.preventDefault()
evt.stop()
}
},
{ priority: 'highest' }
)
}
} }
function getNodePosition(editor: any, modelPosition: any) { function getNodePosition(editor: any, modelPosition: any) {
try { try {
const mapper = editor.editing.mapper const mapper = editor.editing.mapper
const viewPosition = mapper.toViewPosition(modelPosition) const viewPosition = mapper.toViewPosition(modelPosition)
const viewRange = editor.editing.view.createRange(viewPosition) const viewRange = editor.editing.view.createRange(viewPosition)
const domConverter = editor.editing.view.domConverter const domConverter = editor.editing.view.domConverter
const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop() const rangeRects = Rect.getDomRangeRects(domConverter.viewRangeToDom(viewRange)).pop()
return rangeRects return rangeRects
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
} }
// function testOutputToRanges(start: any, arrays: any[], model: any) { // function testOutputToRanges(start: any, arrays: any[], model: any) {
@@ -192,40 +198,43 @@ function getNodePosition(editor: any, modelPosition: any) {
// } // }
function getTextAfterCode(range: any, model: any) { function getTextAfterCode(range: any, model: any) {
let start = range.start let start = range.start
const text = Array.from(range.getItems()).reduce((rangeText: any, node: any) => { const text = Array.from(range.getItems()).reduce((rangeText: any, node: any) => {
// Trim text to a last occurrence of an inline element and update range start. // Trim text to a last occurrence of an inline element and update range start.
if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) { if (!(node.is('$text') || node.is('$textProxy')) || node.getAttribute('code')) {
start = model.createPositionAfter(node) start = model.createPositionAfter(node)
return '' return ''
} }
return rangeText + node.data return rangeText + node.data
}, '') }, '')
return { text, range: model.createRange(start, range.end) } return { text, range: model.createRange(start, range.end) }
} }
function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) { function fireAutocompleteEvent(editor: any, show: boolean, autocompleteNode?: any) {
let event: AutocompleteEvent let event: AutocompleteEvent
if (show && autocompleteNode) { if (show && autocompleteNode) {
const view = editor.editing.view const view = editor.editing.view
const viewPosition = view.document.selection.focus const viewPosition = view.document.selection.focus
const viewNode = viewPosition?.parent.parent const viewNode = viewPosition?.parent.parent
const domElement = viewNode const domElement = viewNode
? (view.domConverter.mapViewToDom(viewNode) as HTMLElement) ? (view.domConverter.mapViewToDom(viewNode) as HTMLElement)
: undefined : undefined
event = { event = {
position: getNodePosition( position: getNodePosition(
editor, editor,
editor.model.createPositionFromPath(autocompleteNode.root, autocompleteNode.getPath()) editor.model.createPositionFromPath(
), autocompleteNode.root,
autocompleteText: autocompleteNode.data, autocompleteNode.getPath()
domElement, )
show: true ),
autocompleteText: autocompleteNode.data,
domElement,
show: true
}
} else {
event = {
show: false
}
} }
} else { editor.model.document.fire('contextedLinkAutocomplete', event)
event = {
show: false
}
}
editor.model.document.fire('contextedLinkAutocomplete', event)
} }

View File

@@ -7,7 +7,7 @@ import { FirebaseAuthentication } from '@capacitor-firebase/authentication'
import { getAuth, GoogleAuthProvider, signInWithCredential } from 'firebase/auth' import { getAuth, GoogleAuthProvider, signInWithCredential } from 'firebase/auth'
const emit = defineEmits<{ const emit = defineEmits<{
signedIn: [authResult: any] signedIn: [authResult: any]
}>() }>()
// const ui: any = inject('firebaseAuthUI') // const ui: any = inject('firebaseAuthUI')
@@ -15,116 +15,120 @@ const auth = getAuth()
const firebaseAuthUI = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth) const firebaseAuthUI = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth)
const uiConfig = { const uiConfig = {
signInOptions: [ signInOptions: [
firebase.auth.EmailAuthProvider.PROVIDER_ID firebase.auth.EmailAuthProvider.PROVIDER_ID
// firebase.auth.GoogleAuthProvider.PROVIDER_ID // firebase.auth.GoogleAuthProvider.PROVIDER_ID
], ],
// signInFlow: 'popup', // signInFlow: 'popup',
signInFlow: 'redirect', signInFlow: 'redirect',
callbacks: { callbacks: {
signInSuccessWithAuthResult(authResult: any) { signInSuccessWithAuthResult(authResult: any) {
// var user = authResult.user // var user = authResult.user
// var credential = authResult.credential // var credential = authResult.credential
// var isNewUser = authResult.additionalUserInfo.isNewUser // var isNewUser = authResult.additionalUserInfo.isNewUser
// var providerId = authResult.additionalUserInfo.providerId // var providerId = authResult.additionalUserInfo.providerId
// var operationType = authResult.operationType // var operationType = authResult.operationType
// Do something with the returned AuthResult. // Do something with the returned AuthResult.
// Return type determines whether we continue the redirect // Return type determines whether we continue the redirect
// automatically or whether we leave that to developer to handle. // automatically or whether we leave that to developer to handle.
emit('signedIn', authResult) emit('signedIn', authResult)
return false return false
}, },
signInFailure(error: any) { signInFailure(error: any) {
console.error('Error signing in', error) console.error('Error signing in', error)
}
} }
} // Other config options...
// Other config options...
} }
// onMounted(() => ui.start('#auth', uiConfig)) // onMounted(() => ui.start('#auth', uiConfig))
interface Provider { interface Provider {
name: 'google' | 'microsoft' | 'github' name: 'google' | 'microsoft' | 'github'
icon: string icon: string
signin: () => Promise<void> signin: () => Promise<void>
// (options?: SignInOptions) => Promise<SignInResult> // (options?: SignInOptions) => Promise<SignInResult>
} }
const providers: Provider[] = [ const providers: Provider[] = [
{ {
name: 'google', name: 'google',
icon: 'fa-brands fa-google', icon: 'fa-brands fa-google',
signin: async () => { signin: async () => {
const result = await FirebaseAuthentication.signInWithGoogle({ const result = await FirebaseAuthentication.signInWithGoogle({
mode: 'redirect' mode: 'redirect'
}) })
const credential = GoogleAuthProvider.credential(result.credential?.idToken) const credential = GoogleAuthProvider.credential(result.credential?.idToken)
await signInWithCredential(auth, credential) await signInWithCredential(auth, credential)
}
} }
} // {
// { // name: 'microsoft',
// name: 'microsoft', // icon: 'fa-brands fa-microsoft',
// icon: 'fa-brands fa-microsoft', // signin: async () => {
// signin: async () => { // const result = await FirebaseAuthentication.signInWithMicrosoft({
// const result = await FirebaseAuthentication.signInWithMicrosoft({ // mode: 'redirect'
// mode: 'redirect' // })
// }) // const provider = new OAuthProvider('microsoft.com')
// const provider = new OAuthProvider('microsoft.com') // const credential = provider.credential({
// const credential = provider.credential({ // idToken: result.credential?.idToken,
// idToken: result.credential?.idToken, // rawNonce: result.credential?.nonce
// rawNonce: result.credential?.nonce // })
// }) // await signInWithCredential(auth, credential)
// await signInWithCredential(auth, credential) // }
// } // },
// }, // {
// { // name: 'github',
// name: 'github', // icon: 'fa-brands fa-github',
// icon: 'fa-brands fa-github', // signin: async () => {
// signin: async () => { // const result = await FirebaseAuthentication.signInWithGithub({
// const result = await FirebaseAuthentication.signInWithGithub({ // mode: 'redirect'
// mode: 'redirect' // })
// }) // const provider = new OAuthProvider('github.com')
// const provider = new OAuthProvider('github.com') // const credential = provider.credential({
// const credential = provider.credential({ // idToken: result.credential?.idToken,
// idToken: result.credential?.idToken, // rawNonce: result.credential?.nonce
// rawNonce: result.credential?.nonce // })
// }) // await signInWithCredential(auth, credential)
// await signInWithCredential(auth, credential) // }
// } // }
// }
] ]
// type Provider = (typeof providers)[number] // type Provider = (typeof providers)[number]
const signInWithProvider = async (provider: Provider) => { const signInWithProvider = async (provider: Provider) => {
provider.signin() provider.signin()
} }
const signingInWithEmail = ref(false) const signingInWithEmail = ref(false)
const signInWithEmail = () => { const signInWithEmail = () => {
firebaseAuthUI.start('#auth', uiConfig) firebaseAuthUI.start('#auth', uiConfig)
signingInWithEmail.value = true signingInWithEmail.value = true
} }
</script> </script>
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<template v-if="!signingInWithEmail"> <template v-if="!signingInWithEmail">
<UIButton <UIButton
class="mx-auto !block w-[225px] max-sm:w-full" class="mx-auto !block w-[225px] max-sm:w-full"
size="sm" size="sm"
@click="signInWithProvider(provider)" @click="signInWithProvider(provider)"
v-for="provider in providers" v-for="provider in providers"
:key="provider.name" :key="provider.name"
> >
<i class="fa-fw mr-2" :class="provider.icon"></i> <i class="fa-fw mr-2" :class="provider.icon"></i>
Sign in with {{ provider.name }} Sign in with {{ provider.name }}
</UIButton> </UIButton>
<UIButton class="mx-auto !block w-[225px] max-sm:w-full" size="sm" @click="signInWithEmail"> <UIButton
<i class="fa-fw fa-regular fa-envelope mr-2"></i> class="mx-auto !block w-[225px] max-sm:w-full"
Sign in with email size="sm"
</UIButton> @click="signInWithEmail"
</template> >
<div id="auth"></div> <i class="fa-fw fa-regular fa-envelope mr-2"></i>
<!-- <progress Sign in with email
</UIButton>
</template>
<div id="auth"></div>
<!-- <progress
v-show="props.authenticating" v-show="props.authenticating"
class="dui-progress dui-progress-primary w-full" class="dui-progress dui-progress-primary w-full"
></progress> --> ></progress> -->
</div> </div>
</template> </template>

View File

@@ -2,60 +2,60 @@
import { notes, findNotesByByTitle, activeNote } from '@/composables/useNotes' import { notes, findNotesByByTitle, activeNote } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
autocompleteText: string autocompleteText: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
createLink: [title: string] createLink: [title: string]
}>() }>()
const results = computed<Note[]>(() => { const results = computed<Note[]>(() => {
return (props.autocompleteText ? findNotesByByTitle(props.autocompleteText) : notes.value) return (props.autocompleteText ? findNotesByByTitle(props.autocompleteText) : notes.value)
.filter((note) => note.id !== activeNote.value?.id) .filter((note) => note.id !== activeNote.value?.id)
.slice(0, 10) .slice(0, 10)
}) })
const activeResult = ref<Note>() const activeResult = ref<Note>()
const changeActiveResult = (direction: number) => { const changeActiveResult = (direction: number) => {
const index = results.value.findIndex((note) => note.id === activeResult.value?.id) const index = results.value.findIndex((note) => note.id === activeResult.value?.id)
const newIndex = const newIndex =
index + direction < results.value.length index + direction < results.value.length
? index + direction >= -1 ? index + direction >= -1
? index + direction ? index + direction
: results.value.length - 1 : results.value.length - 1
: -1 : -1
activeResult.value = newIndex >= 0 ? results.value[newIndex] : undefined activeResult.value = newIndex >= 0 ? results.value[newIndex] : undefined
} }
const handleKeypress = (event: { [key: string]: number }) => { const handleKeypress = (event: { [key: string]: number }) => {
const keyCode = event.keyCode const keyCode = event.keyCode
const keyCodes = { const keyCodes = {
cycle: [38, 40], cycle: [38, 40],
confirm: [13] confirm: [13]
} }
if (keyCodes.cycle.includes(keyCode)) { if (keyCodes.cycle.includes(keyCode)) {
const direction = keyCode === 38 ? -1 : 1 const direction = keyCode === 38 ? -1 : 1
changeActiveResult(direction) changeActiveResult(direction)
} else if (keyCodes.confirm.includes(keyCode)) { } else if (keyCodes.confirm.includes(keyCode)) {
const contextedLink = activeResult.value ? activeResult.value.title : props.autocompleteText const contextedLink = activeResult.value ? activeResult.value.title : props.autocompleteText
emit('createLink', contextedLink) emit('createLink', contextedLink)
} }
} }
defineExpose({ handleKeypress }) defineExpose({ handleKeypress })
</script> </script>
<template> <template>
<UIMenu class="border-[1px] p-2 text-[0.875rem] text-black shadow-md" compact> <UIMenu class="border-[1px] p-2 text-[0.875rem] text-black shadow-md" compact>
<UIMenuItem :active="!activeResult" @click="emit('createLink', props.autocompleteText)"> <UIMenuItem :active="!activeResult" @click="emit('createLink', props.autocompleteText)">
<span class="flex-grow">{{ props.autocompleteText }}</span> <span class="flex-grow">{{ props.autocompleteText }}</span>
<i class="fas fa-plus-circle ml-auto text-white" /> <i class="fas fa-plus-circle ml-auto text-white" />
</UIMenuItem> </UIMenuItem>
<SearchResult <SearchResult
v-for="result in results" v-for="result in results"
:key="result.id" :key="result.id"
:result="result" :result="result"
:active-result="activeResult" :active-result="activeResult"
@go-to-note="emit('createLink', result.title)" @go-to-note="emit('createLink', result.title)"
/> />
</UIMenu> </UIMenu>
</template> </template>

View File

@@ -19,7 +19,7 @@ import { vibrate } from '@/composables/useHaptics'
const props = defineProps<{ note: Note }>() const props = defineProps<{ note: Note }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [mdText: string] update: [mdText: string]
}>() }>()
const html = mdToHtml(props.note.content) const html = mdToHtml(props.note.content)
@@ -27,34 +27,34 @@ const html = mdToHtml(props.note.content)
const editor = BalloonEditor const editor = BalloonEditor
const editorData = ref<string>(html) const editorData = ref<string>(html)
const editorConfig = { const editorConfig = {
plugins: [ plugins: [
EssentialsPlugin, EssentialsPlugin,
BoldPlugin, BoldPlugin,
ItalicPlugin, ItalicPlugin,
UnderlinePlugin, UnderlinePlugin,
StrikethroughPlugin, StrikethroughPlugin,
LinkPlugin, LinkPlugin,
HeadingPlugin, HeadingPlugin,
ParagraphPlugin, ParagraphPlugin,
ListPlugin, ListPlugin,
AutoformatPlugin, AutoformatPlugin,
ContextedPlugin ContextedPlugin
], ],
toolbar: { toolbar: {
items: [ items: [
'bold', 'bold',
'italic', 'italic',
'underline', 'underline',
'strikethrough', 'strikethrough',
'link', 'link',
'undo', 'undo',
'redo', 'redo',
'heading', 'heading',
'bulletedList', 'bulletedList',
'numberedList' 'numberedList'
] ]
}, },
placeholder: 'Click here to start typing...' placeholder: 'Click here to start typing...'
} }
const editorElement = ref<HTMLInputElement | null>(null) const editorElement = ref<HTMLInputElement | null>(null)
@@ -62,12 +62,12 @@ watch(editorData, () => emit('update', htmlToMd(editorData.value)))
let editorInstance: any let editorInstance: any
const handleClick = async ({ data }: { data: any }) => { const handleClick = async ({ data }: { data: any }) => {
if (!data.domTarget.hasAttribute('data-contexted-link')) return if (!data.domTarget.hasAttribute('data-contexted-link')) return
const noteTitle = data.domTarget.textContent as string const noteTitle = data.domTarget.textContent as string
let note: BaseNote | Note | undefined = getNoteByTitle(noteTitle) let note: BaseNote | Note | undefined = getNoteByTitle(noteTitle)
if (!note) note = addNote(noteTitle, '') if (!note) note = addNote(noteTitle, '')
setActiveNote(note.id) setActiveNote(note.id)
await vibrate() await vibrate()
} }
const autocompleteRef = ref<InstanceType<typeof Autocomplete> | null>(null) const autocompleteRef = ref<InstanceType<typeof Autocomplete> | null>(null)
@@ -77,108 +77,108 @@ const autocompleteText = ref<string>('')
const autocompleteReverse = ref<boolean>(false) const autocompleteReverse = ref<boolean>(false)
const handleAutocomplete = async (event: AutocompleteEvent) => { const handleAutocomplete = async (event: AutocompleteEvent) => {
const position = event.position const position = event.position
if (position && editorElement.value) { if (position && editorElement.value) {
const rect: any = editorElement.value?.getBoundingClientRect() const rect: any = editorElement.value?.getBoundingClientRect()
const lineHeight = parseFloat( const lineHeight = parseFloat(
window.getComputedStyle(event.domElement || editorElement.value).lineHeight window.getComputedStyle(event.domElement || editorElement.value).lineHeight
) )
autocompleteStyle.value = { autocompleteStyle.value = {
top: `${position.top - rect.top + lineHeight}px`, top: `${position.top - rect.top + lineHeight}px`,
left: `${position.left - rect.left}px` left: `${position.left - rect.left}px`
}
} }
} autocompleteText.value = event.autocompleteText || ''
autocompleteText.value = event.autocompleteText || '' showAutocomplete.value = event.show
showAutocomplete.value = event.show await nextTick()
await nextTick() const autocompleteElem = autocompleteRef.value?.$el
const autocompleteElem = autocompleteRef.value?.$el const autocompleteRect = autocompleteRef.value?.$el.getBoundingClientRect()
const autocompleteRect = autocompleteRef.value?.$el.getBoundingClientRect() const editorRect = editorElement.value?.getBoundingClientRect()
const editorRect = editorElement.value?.getBoundingClientRect() if (
if ( autocompleteElem &&
autocompleteElem && autocompleteRect &&
autocompleteRect && editorRect &&
editorRect && autocompleteRect.bottom > editorRect.bottom
autocompleteRect.bottom > editorRect.bottom ) {
) { const autocompleteHeight = parseFloat(window.getComputedStyle(autocompleteElem).height)
const autocompleteHeight = parseFloat(window.getComputedStyle(autocompleteElem).height) autocompleteStyle.value = {
autocompleteStyle.value = { ...autocompleteStyle.value,
...autocompleteStyle.value, top: `${position.top - editorRect.top - autocompleteHeight}px`
top: `${position.top - editorRect.top - autocompleteHeight}px` }
autocompleteReverse.value = true
} else {
autocompleteReverse.value = false
} }
autocompleteReverse.value = true
} else {
autocompleteReverse.value = false
}
} }
const handleContextedKeypress = (event: any) => { const handleContextedKeypress = (event: any) => {
if (autocompleteRef.value) autocompleteRef.value.handleKeypress(event) if (autocompleteRef.value) autocompleteRef.value.handleKeypress(event)
} }
const createLink = (link: string) => { const createLink = (link: string) => {
if (!editor) return if (!editor) return
const model = editorInstance.model const model = editorInstance.model
const getPosition = () => model.document.selection.anchor const getPosition = () => model.document.selection.anchor
// const getNodes = () => { // const getNodes = () => {
// const nodes = [ // const nodes = [
// getPosition().nodeBefore, // getPosition().nodeBefore,
// getPosition().nodeAfter, // getPosition().nodeAfter,
// getPosition().textNnode, // getPosition().textNnode,
// ] // ]
// return nodes.map((node) => ({ // return nodes.map((node) => ({
// data: node?.data, // data: node?.data,
// attrs: Array.from(node?.getAttributes() || []), // attrs: Array.from(node?.getAttributes() || []),
// })) // }))
// } // }
// console.log(getNodes()) // console.log(getNodes())
let nodeToRemove: any let nodeToRemove: any
if (getPosition().nodeBefore?.hasAttribute('autocomplete')) { if (getPosition().nodeBefore?.hasAttribute('autocomplete')) {
// Insert new link // Insert new link
nodeToRemove = getPosition().nodeBefore nodeToRemove = getPosition().nodeBefore
} else if (getPosition().nodeBefore?.hasAttribute('contextedLink')) { } else if (getPosition().nodeBefore?.hasAttribute('contextedLink')) {
// Update existing link from end of existing link (backspace) // Update existing link from end of existing link (backspace)
nodeToRemove = getPosition().nodeBefore nodeToRemove = getPosition().nodeBefore
} else if (getPosition().textNode?.hasAttribute('contextedLink')) { } else if (getPosition().textNode?.hasAttribute('contextedLink')) {
// Update existing link from middle of existing link // Update existing link from middle of existing link
nodeToRemove = getPosition().textNode nodeToRemove = getPosition().textNode
} else if (getPosition().nodeAfter?.hasAttribute('contextedLink')) { } else if (getPosition().nodeAfter?.hasAttribute('contextedLink')) {
// Update existing link from beginning (delete) // Update existing link from beginning (delete)
nodeToRemove = getPosition().nodeAfter nodeToRemove = getPosition().nodeAfter
} }
model.change((writer: any) => { model.change((writer: any) => {
if (nodeToRemove) writer.remove(nodeToRemove) if (nodeToRemove) writer.remove(nodeToRemove)
writer.insertText(link, { contextedLink: true }, getPosition(), 'after') writer.insertText(link, { contextedLink: true }, getPosition(), 'after')
model.enqueueChange((writer: any) => { model.enqueueChange((writer: any) => {
const nodeAfter = getPosition().nodeAfter const nodeAfter = getPosition().nodeAfter
if (!nodeAfter || (nodeAfter && !nodeAfter.data.startsWith(']]'))) { if (!nodeAfter || (nodeAfter && !nodeAfter.data.startsWith(']]'))) {
writer.insertText(']]', model.document.selection.getFirstPosition()) writer.insertText(']]', model.document.selection.getFirstPosition())
} }
})
showAutocomplete.value = false
}) })
showAutocomplete.value = false
})
} }
</script> </script>
<template> <template>
<div class="relative" ref="editorElement"> <div class="relative" ref="editorElement">
<CKEditor <CKEditor
class="w-full flex-grow text-[110%] font-light" class="w-full flex-grow text-[110%] font-light"
:editor="editor" :editor="editor"
v-model="editorData" v-model="editorData"
:config="editorConfig" :config="editorConfig"
@editor-ready="(editor) => (editorInstance = editor)" @editor-ready="(editor) => (editorInstance = editor)"
@click="handleClick" @click="handleClick"
@contexted-link-autocomplete="handleAutocomplete" @contexted-link-autocomplete="handleAutocomplete"
@contexted-keypress="handleContextedKeypress" @contexted-keypress="handleContextedKeypress"
></CKEditor> ></CKEditor>
<Autocomplete <Autocomplete
v-if="showAutocomplete" v-if="showAutocomplete"
ref="autocompleteRef" ref="autocompleteRef"
:autocomplete-text="autocompleteText" :autocomplete-text="autocompleteText"
:style="autocompleteStyle" :style="autocompleteStyle"
@create-link="createLink" @create-link="createLink"
class="absolute w-[300px]" class="absolute w-[300px]"
:class="autocompleteReverse && 'flex-col-reverse'" :class="autocompleteReverse && 'flex-col-reverse'"
/> />
</div> </div>
</template> </template>

View File

@@ -1,22 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { setActiveNote } from '@/composables/useNotes' import { setActiveNote } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
references: Note[] references: Note[]
}>() }>()
</script> </script>
<template> <template>
<UIMenu class="mt-3 rounded-xl border-[1px] px-3 py-3" v-if="props.references.length > 0"> <UIMenu class="mt-3 rounded-xl border-[1px] px-3 py-3" v-if="props.references.length > 0">
<UIMenuItem title> <UIMenuItem title>
<span>References</span> <span>References</span>
<UIBadge variant="outline" class="ml-2">{{ props.references.length }}</UIBadge> <UIBadge variant="outline" class="ml-2">{{ props.references.length }}</UIBadge>
</UIMenuItem> </UIMenuItem>
<UIMenuItem <UIMenuItem
v-for="reference in props.references" v-for="reference in props.references"
:key="reference.id" :key="reference.id"
@click="setActiveNote(reference.id)" @click="setActiveNote(reference.id)"
> >
<i class="far fa-file-alt fa-fw" /> <i class="far fa-file-alt fa-fw" />
{{ reference.title }} {{ reference.title }}
</UIMenuItem> </UIMenuItem>
</UIMenu> </UIMenu>
</template> </template>

View File

@@ -5,75 +5,84 @@ import type { ConfirmOptions } from '@capacitor/dialog'
import { vibrate } from '@/composables/useHaptics' import { vibrate } from '@/composables/useHaptics'
const props = defineProps<{ const props = defineProps<{
note: Note note: Note
}>() }>()
type ActionKey = 'delete' | 'setRoot' type ActionKey = 'delete' | 'setRoot'
interface ModalOptions { interface ModalOptions {
key: ActionKey key: ActionKey
icon: string icon: string
confirmOptions: ConfirmOptions confirmOptions: ConfirmOptions
} }
const confirmModals: ModalOptions[] = [ const confirmModals: ModalOptions[] = [
{ {
key: 'delete', key: 'delete',
icon: 'fas fa-fw fa-trash', icon: 'fas fa-fw fa-trash',
confirmOptions: { confirmOptions: {
title: 'Delete note', title: 'Delete note',
message: 'Are you sure you want to delete this note?', message: 'Are you sure you want to delete this note?',
okButtonTitle: 'Delete note' okButtonTitle: 'Delete note'
}
},
{
key: 'setRoot',
icon: 'fas fa-fw fa-sitemap',
confirmOptions: {
title: 'Set root note',
message: 'Are you sure you want to set this note as root note?',
okButtonTitle: 'Set note as root note'
}
} }
},
{
key: 'setRoot',
icon: 'fas fa-fw fa-sitemap',
confirmOptions: {
title: 'Set root note',
message: 'Are you sure you want to set this note as root note?',
okButtonTitle: 'Set note as root note'
}
}
] ]
const emit = defineEmits<{ const emit = defineEmits<{
execute: [actionType: ActionKey, close?: () => Promise<void>] execute: [actionType: ActionKey, close?: () => Promise<void>]
}>() }>()
const openModal = async (open: () => void, modal: ModalOptions) => { const openModal = async (open: () => void, modal: ModalOptions) => {
if (['android', 'ios'].includes(Capacitor.getPlatform())) { if (['android', 'ios'].includes(Capacitor.getPlatform())) {
const { value: confirmed } = await Dialog.confirm(modal.confirmOptions) const { value: confirmed } = await Dialog.confirm(modal.confirmOptions)
if (confirmed) emit('execute', modal.key) if (confirmed) emit('execute', modal.key)
} else { } else {
open() open()
} }
} }
</script> </script>
<template> <template>
<div class="mb-2 flex items-center space-x-2"> <div class="mb-2 flex items-center space-x-2">
<h1 class="flex flex-grow items-center rounded-md text-3xl font-semibold hover:bg-gray-200"> <h1 class="flex flex-grow items-center rounded-md text-3xl font-semibold hover:bg-gray-200">
<slot name="title"></slot> <slot name="title"></slot>
</h1> </h1>
<UIButtonGroup class="flex items-center" v-if="!props.note.isRoot"> <UIButtonGroup class="flex items-center" v-if="!props.note.isRoot">
<UIModal v-for="confirmModal in confirmModals" :key="confirmModal.key"> <UIModal v-for="confirmModal in confirmModals" :key="confirmModal.key">
<template #activator="{ open }"> <template #activator="{ open }">
<UIButton size="sm" @click="openModal(open, confirmModal)" @mousedown="vibrate" join> <UIButton
<i :class="confirmModal.icon" /> size="sm"
</UIButton> @click="openModal(open, confirmModal)"
</template> @mousedown="vibrate"
<template #title> join
<i class="mr-2" :class="confirmModal.icon" /> >
{{ confirmModal.confirmOptions.title }} <i :class="confirmModal.icon" />
</template> </UIButton>
<template #default>{{ confirmModal.confirmOptions.message }}</template> </template>
<template #actions="{ close }"> <template #title>
<UIButton size="sm" @click="close">Cancel</UIButton> <i class="mr-2" :class="confirmModal.icon" />
<UIButton size="sm" color="primary" @click="emit('execute', confirmModal.key, close)"> {{ confirmModal.confirmOptions.title }}
{{ confirmModal.confirmOptions.okButtonTitle }} </template>
</UIButton> <template #default>{{ confirmModal.confirmOptions.message }}</template>
</template> <template #actions="{ close }">
</UIModal> <UIButton size="sm" @click="close">Cancel</UIButton>
</UIButtonGroup> <UIButton
</div> size="sm"
color="primary"
@click="emit('execute', confirmModal.key, close)"
>
{{ confirmModal.confirmOptions.okButtonTitle }}
</UIButton>
</template>
</UIModal>
</UIButtonGroup>
</div>
</template> </template>

View File

@@ -3,84 +3,85 @@ import { notes, findNotes, setActiveNote } from '@/composables/useNotes'
import SearchResult from '@/components/Search/SearchResult.vue' import SearchResult from '@/components/Search/SearchResult.vue'
const emit = defineEmits<{ const emit = defineEmits<{
active: [active: boolean] active: [active: boolean]
}>() }>()
const active = ref<boolean>(false) const active = ref<boolean>(false)
watch(active, () => { watch(active, () => {
if (!active.value) { if (!active.value) {
query.value = '' query.value = ''
activeResult.value = undefined activeResult.value = undefined
} }
emit('active', active.value) emit('active', active.value)
}) })
const query = ref<string>('') const query = ref<string>('')
const results = computed<Note[]>(() => { const results = computed<Note[]>(() => {
return query.value ? findNotes(query.value) : notes.value return query.value ? findNotes(query.value) : notes.value
}) })
const goToNote = (note: Note) => { const goToNote = (note: Note) => {
setActiveNote(note.id) setActiveNote(note.id)
active.value = false active.value = false
if (queryElem.value) queryElem.value.blur() if (queryElem.value) queryElem.value.blur()
} }
const queryElem = ref<HTMLInputElement | null>(null) const queryElem = ref<HTMLInputElement | null>(null)
const activeResult = ref<Note>() const activeResult = ref<Note>()
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
const code = event.code const code = event.code
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) { if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) {
let index = results.value.findIndex((note) => note.id === activeResult.value?.id) let index = results.value.findIndex((note) => note.id === activeResult.value?.id)
if (['ArrowDown', 'Tab'].includes(code)) { if (['ArrowDown', 'Tab'].includes(code)) {
index++ index++
} else if (['ArrowUp'].includes(code)) { } else if (['ArrowUp'].includes(code)) {
index-- index--
}
if (index + 1 > results.value.length) index = index - results.value.length
if (index < 0) index = results.value.length - 1
activeResult.value = results.value[index]
const element = resultsRefs.value[index].$el
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code))
element.scrollIntoView({ block: 'nearest' })
} else if (code === 'Enter' && activeResult.value) {
goToNote(activeResult.value)
} else if (code === 'Escape' && queryElem.value) {
queryElem.value.blur()
} }
if (index + 1 > results.value.length) index = index - results.value.length
if (index < 0) index = results.value.length - 1
activeResult.value = results.value[index]
const element = resultsRefs.value[index].$el
if (['ArrowUp', 'ArrowDown', 'Tab'].includes(code)) element.scrollIntoView({ block: 'nearest' })
} else if (code === 'Enter' && activeResult.value) {
goToNote(activeResult.value)
} else if (code === 'Escape' && queryElem.value) {
queryElem.value.blur()
}
} }
const resultsRefs = ref<InstanceType<typeof SearchResult>[]>([]) const resultsRefs = ref<InstanceType<typeof SearchResult>[]>([])
</script> </script>
<template> <template>
<div id="search-container" class="relative h-full flex-grow"> <div id="search-container" class="relative h-full flex-grow">
<input <input
type="text" type="text"
placeholder="Search for notes" placeholder="Search for notes"
class="h-full w-full rounded border-0 bg-white/10 px-2 text-white outline-none placeholder:text-white focus:bg-white focus:text-black" class="h-full w-full rounded border-0 bg-white/10 px-2 text-white outline-none placeholder:text-white focus:bg-white focus:text-black"
@focus="active = true" @focus="active = true"
@mousedown="active = true" @mousedown="active = true"
@blur="active = false" @blur="active = false"
v-model="query" v-model="query"
ref="queryElem" ref="queryElem"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
<div class="z-1000 absolute left-0 right-0 top-[100%]" v-if="active"> <div class="z-1000 absolute left-0 right-0 top-[100%]" v-if="active">
<UIMenu compact class="mt-1 w-full rounded-md bg-base-100 p-2 text-black shadow"> <UIMenu compact class="mt-1 w-full rounded-md bg-base-100 p-2 text-black shadow">
<div class="max-h-[320px] w-full overflow-y-auto"> <div class="max-h-[320px] w-full overflow-y-auto">
<template v-if="results.length > 0"> <template v-if="results.length > 0">
<SearchResult <SearchResult
v-for="result in results" v-for="result in results"
:key="result.id" :key="result.id"
:result="result" :result="result"
:active-result="activeResult" :active-result="activeResult"
@go-to-note="goToNote(result)" @go-to-note="goToNote(result)"
ref="resultsRefs" ref="resultsRefs"
/> />
</template> </template>
<UIMenuItem :compact="true" v-else>No notes found</UIMenuItem> <UIMenuItem :compact="true" v-else>No notes found</UIMenuItem>
</div>
</UIMenu>
</div> </div>
</UIMenu>
</div> </div>
</div>
</template> </template>

View File

@@ -3,28 +3,28 @@ import { activeNote } from '@/composables/useNotes'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps<{ const props = defineProps<{
result: Note result: Note
activeResult?: Note activeResult?: Note
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
goToNote: [element: HTMLElement | null] goToNote: [element: HTMLElement | null]
}>() }>()
const element = ref<HTMLElement | null>(null) const element = ref<HTMLElement | null>(null)
</script> </script>
<template> <template>
<UIMenuItem <UIMenuItem
class="flex w-full items-center" class="flex w-full items-center"
@click.stop.prevent="() => emit('goToNote', element)" @click.stop.prevent="() => emit('goToNote', element)"
@mousedown.prevent @mousedown.prevent
:disabled="activeNote?.id === result.id" :disabled="activeNote?.id === result.id"
:active="props.activeResult?.id === result.id" :active="props.activeResult?.id === result.id"
> >
<UIBadge size="sm" variant="ghost" class="mr-0.5" v-if="activeNote?.id === result.id"> <UIBadge size="sm" variant="ghost" class="mr-0.5" v-if="activeNote?.id === result.id">
current current
</UIBadge> </UIBadge>
<span class="flex-grow truncate">{{ result.title }}</span> <span class="flex-grow truncate">{{ result.title }}</span>
<span class="whitespace-nowrap">{{ formatDate(result.modified) }}</span> <span class="whitespace-nowrap">{{ formatDate(result.modified) }}</span>
</UIMenuItem> </UIMenuItem>
</template> </template>

View File

@@ -6,80 +6,80 @@ import { activeViewMode } from '@/composables/useViewMode'
const loading = inject<boolean>('loading') const loading = inject<boolean>('loading')
const props = defineProps<{ const props = defineProps<{
viewModes: ViewMode[] viewModes: ViewMode[]
activeViewMode: ViewMode activeViewMode: ViewMode
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
setViewMode: [viewMode: ViewMode] setViewMode: [viewMode: ViewMode]
collapse: [collapse: boolean] collapse: [collapse: boolean]
}>() }>()
const setActiveNote = (noteId: string | undefined) => { const setActiveNote = (noteId: string | undefined) => {
emit('collapse', windowIsMobile()) emit('collapse', windowIsMobile())
baseSetActiveNote(noteId) baseSetActiveNote(noteId)
} }
const setViewMode = (viewMode: ViewMode) => { const setViewMode = (viewMode: ViewMode) => {
emit('collapse', windowIsMobile()) emit('collapse', windowIsMobile())
emit('setViewMode', viewMode) emit('setViewMode', viewMode)
} }
</script> </script>
<template> <template>
<div <div
id="sidebar" id="sidebar"
class="fixed bottom-0 top-0 flex flex-col gap-4 overflow-y-auto px-2 py-3 text-[90%] max-sm:w-sidebar-mobile max-sm:gap-6 max-sm:text-[110%] sm:w-sidebar" class="fixed bottom-0 top-0 flex flex-col gap-4 overflow-y-auto px-2 py-3 text-[90%] max-sm:w-sidebar-mobile max-sm:gap-6 max-sm:text-[110%] sm:w-sidebar"
> >
<SideBarMenu> <SideBarMenu>
<template #header>Root note</template> <template #header>Root note</template>
<template #items> <template #items>
<SideBarMenuItem <SideBarMenuItem
icon="fas fa-fw fa-home" icon="fas fa-fw fa-home"
@click="setActiveNote(rootNote?.id)" @click="setActiveNote(rootNote?.id)"
:title="rootNote?.title" :title="rootNote?.title"
v-if="!loading" v-if="!loading"
> >
{{ rootNote?.title }} {{ rootNote?.title }}
</SideBarMenuItem> </SideBarMenuItem>
<SkeletonSidebarItem v-else /> <SkeletonSidebarItem v-else />
</template> </template>
</SideBarMenu> </SideBarMenu>
<SideBarMenu> <SideBarMenu>
<template #header>View mode</template> <template #header>View mode</template>
<template #items> <template #items>
<template v-if="!loading"> <template v-if="!loading">
<SideBarMenuItem <SideBarMenuItem
v-for="viewMode in props.viewModes" v-for="viewMode in props.viewModes"
:key="viewMode.name" :key="viewMode.name"
:icon="viewMode.icon" :icon="viewMode.icon"
:active="viewMode.name === activeViewMode.name" :active="viewMode.name === activeViewMode.name"
@click="setViewMode(viewMode)" @click="setViewMode(viewMode)"
> >
{{ viewMode.name }} {{ viewMode.name }}
</SideBarMenuItem> </SideBarMenuItem>
</template> </template>
<SkeletonSidebarItem :n="3" v-else /> <SkeletonSidebarItem :n="3" v-else />
</template> </template>
</SideBarMenu> </SideBarMenu>
<SideBarMenu> <SideBarMenu>
<template #header> <template #header>
<i class="far fa-clock fa-fw mr-2" /> <i class="far fa-clock fa-fw mr-2" />
Recent notes Recent notes
</template> </template>
<template #items> <template #items>
<template v-if="!loading"> <template v-if="!loading">
<SideBarMenuItem <SideBarMenuItem
v-for="note in notes.slice(-5)" v-for="note in notes.slice(-5)"
:key="note.id" :key="note.id"
icon="far fa-file-alt fa-fw" icon="far fa-file-alt fa-fw"
@click="setActiveNote(note.id)" @click="setActiveNote(note.id)"
:title="rootNote?.title" :title="rootNote?.title"
> >
{{ note.title }} {{ note.title }}
</SideBarMenuItem> </SideBarMenuItem>
</template> </template>
<SkeletonSidebarItem v-else :n="5" /> <SkeletonSidebarItem v-else :n="5" />
</template> </template>
</SideBarMenu> </SideBarMenu>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<div class="text-sm font-semibold uppercase text-secondary"> <div class="text-sm font-semibold uppercase text-secondary">
<slot name="header"></slot> <slot name="header"></slot>
</div>
<slot name="items"></slot>
</div> </div>
<slot name="items"></slot>
</div>
</template> </template>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
icon?: string icon?: string
active?: boolean active?: boolean
}>() }>()
</script> </script>
<template> <template>
<a <a
class="mt-1 block w-full cursor-pointer truncate rounded hover:bg-gray-200 active:bg-primary active:text-primary-content max-sm:mt-2" class="mt-1 block w-full cursor-pointer truncate rounded hover:bg-gray-200 active:bg-primary active:text-primary-content max-sm:mt-2"
:class="props.active ? 'font-bold text-primary' : 'text-secondary'" :class="props.active ? 'font-bold text-primary' : 'text-secondary'"
> >
<i :class="props.icon" class="mr-2" v-if="props.icon"></i> <i :class="props.icon" class="mr-2" v-if="props.icon"></i>
<slot></slot> <slot></slot>
</a> </a>
</template> </template>

View File

@@ -1,30 +1,30 @@
<template> <template>
<div class="flex h-full w-full animate-pulse flex-col"> <div class="flex h-full w-full animate-pulse flex-col">
<div class="mb-2 flex items-center space-x-4 py-1"> <div class="mb-2 flex items-center space-x-4 py-1">
<div class="h-[2.25rem] w-[40px] rounded bg-secondary"></div> <div class="h-[2.25rem] w-[40px] rounded bg-secondary"></div>
<div class="h-[2.25rem] flex-grow rounded bg-secondary"></div> <div class="h-[2.25rem] flex-grow rounded bg-secondary"></div>
</div>
<div class="flex flex-grow flex-col gap-2">
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="mt-2 h-[2rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-4/6 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-7/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-6/12 rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
</div>
<hr class="my-3" />
<div class="flex gap-2">
<div class="h-[1.25rem] w-2/12 rounded bg-secondary"></div>
<div class="ml-auto h-[1.25rem] w-4/12 rounded bg-secondary"></div>
</div>
</div> </div>
<div class="flex flex-grow flex-col gap-2">
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="mt-2 h-[2rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-4/6 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-5/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-7/12 rounded bg-secondary"></div>
<div class="my-1 ml-8 h-[1.25rem] w-6/12 rounded bg-secondary"></div>
<div class="my-1 h-[1.25rem] w-full rounded bg-secondary"></div>
</div>
<hr class="my-3" />
<div class="flex gap-2">
<div class="h-[1.25rem] w-2/12 rounded bg-secondary"></div>
<div class="ml-auto h-[1.25rem] w-4/12 rounded bg-secondary"></div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.bg-secondary { .bg-secondary {
@apply bg-secondary/25; @apply bg-secondary/25;
} }
</style> </style>

View File

@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
n?: number n?: number
}>(), }>(),
{ n: 1 } { n: 1 }
) )
</script> </script>
<template> <template>
<div class="flex w-full animate-pulse flex-col"> <div class="flex w-full animate-pulse flex-col">
<div class="mt-1 h-[1.35rem] w-full rounded bg-secondary" v-for="i in props.n" :key="i"></div> <div
</div> class="mt-1 h-[1.35rem] w-full rounded bg-secondary"
v-for="i in props.n"
:key="i"
></div>
</div>
</template> </template>
<style scoped> <style scoped>
.bg-secondary { .bg-secondary {
@apply bg-secondary/25; @apply bg-secondary/25;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex h-full w-full animate-pulse space-x-2"> <div class="flex h-full w-full animate-pulse space-x-2">
<div class="h-full w-full rounded bg-white/10" /> <div class="h-full w-full rounded bg-white/10" />
<div class="h-full w-[44px] rounded bg-white/10" /> <div class="h-full w-[44px] rounded bg-white/10" />
</div> </div>
</template> </template>

View File

@@ -6,17 +6,17 @@ import { initialized } from '@/composables/useFirebase'
const loading = inject<boolean>('loading') const loading = inject<boolean>('loading')
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
sideBarCollapsed: boolean sideBarCollapsed: boolean
height?: number height?: number
}>(), }>(),
{ {
height: 52 height: 52
} }
) )
const emit = defineEmits<{ const emit = defineEmits<{
toggleSideBar: [] toggleSideBar: []
}>() }>()
const searchActive = ref<boolean>(false) const searchActive = ref<boolean>(false)
@@ -25,90 +25,90 @@ const searchActive = ref<boolean>(false)
// const authPending = ref<boolean>(authUI.isPendingRedirect()) // const authPending = ref<boolean>(authUI.isPendingRedirect())
const handleSignIn = async (close: () => Promise<void>) => { const handleSignIn = async (close: () => Promise<void>) => {
await close() await close()
// authPending.value = false // authPending.value = false
} }
</script> </script>
<template> <template>
<div class="z-[500] flex items-end bg-primary" :class="searchActive && 'search-active'"> <div class="z-[500] flex items-end bg-primary" :class="searchActive && 'search-active'">
<div <div
class="mx-auto flex w-full max-w-app items-center py-2.5 text-white" class="mx-auto flex w-full max-w-app items-center py-2.5 text-white"
:style="{ height: `${props.height}px` }" :style="{ height: `${props.height}px` }"
> >
<div <div
class="search-active-hide flex items-center pl-3" class="search-active-hide flex items-center pl-3"
:class="sideBarCollapsed ? 'w-fit' : 'max-sm:w-fit md:w-sidebar md:pr-3'" :class="sideBarCollapsed ? 'w-fit' : 'max-sm:w-fit md:w-sidebar md:pr-3'"
> >
<Hamburger <Hamburger
:side-bar-collapsed="props.sideBarCollapsed" :side-bar-collapsed="props.sideBarCollapsed"
@toggle-side-bar="emit('toggleSideBar')" @toggle-side-bar="emit('toggleSideBar')"
/> />
<Logo <Logo
class="ml-auto pl-5 text-2xl hover:drop-shadow" class="ml-auto pl-5 text-2xl hover:drop-shadow"
id="logo" id="logo"
@click="setActiveNote(rootNote?.id)" @click="setActiveNote(rootNote?.id)"
/> />
</div> </div>
<div class="flex h-full flex-grow flex-row items-center gap-2 pl-5 pr-3"> <div class="flex h-full flex-grow flex-row items-center gap-2 pl-5 pr-3">
<template v-if="!loading"> <template v-if="!loading">
<SearchBar @active="(active) => (searchActive = active)" /> <SearchBar @active="(active) => (searchActive = active)" />
<UIButton <UIButton
size="sm" size="sm"
variant="outline" variant="outline"
class="search-active-hide topbar-button text-white" class="search-active-hide topbar-button text-white"
@click="addNote('Untitled new note', '', true)" @click="addNote('Untitled new note', '', true)"
> >
<i class="fa-fw fa-solid fa-plus-circle scale-[115%]" /> <i class="fa-fw fa-solid fa-plus-circle scale-[115%]" />
</UIButton> </UIButton>
<UIModal v-if="initialized && !user"> <UIModal v-if="initialized && !user">
<template #activator="{ open }"> <template #activator="{ open }">
<UIButton <UIButton
size="sm" size="sm"
variant="outline" variant="outline"
class="search-active-hide topbar-button py-1 text-white" class="search-active-hide topbar-button py-1 text-white"
@click="open" @click="open"
> >
Sign in Sign in
</UIButton> </UIButton>
</template> </template>
<template #title>Sign in</template> <template #title>Sign in</template>
<template #default="{ close }"> <template #default="{ close }">
<Auth @signedIn="handleSignIn(close)" /> <Auth @signedIn="handleSignIn(close)" />
</template> </template>
<template #actions="{ close }"> <template #actions="{ close }">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" @click="close">Close</UIButton>
</template> </template>
</UIModal> </UIModal>
<Settings v-else-if="user" /> <Settings v-else-if="user" />
</template> </template>
<SkeletonTopBar v-else /> <SkeletonTopBar v-else />
</div> </div>
</div>
</div> </div>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
#logo { #logo {
@apply cursor-pointer transition-all duration-200 active:text-primary; @apply cursor-pointer transition-all duration-200 active:text-primary;
} }
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
#logo:hover { #logo:hover {
text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white; text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white;
@apply text-primary; @apply text-primary;
} }
} }
#logo:active { #logo:active {
text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white; text-shadow: 0 0 5px white, 0 0 10px white, 0 0 15px white;
} }
.topbar-button { .topbar-button {
&:active { &:active {
@apply border-white bg-white text-primary; @apply border-white bg-white text-primary;
} }
@apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white; @apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white;
} }
.search-active { .search-active {
.search-active-hide { .search-active-hide {
@apply max-sm:hidden; @apply max-sm:hidden;
} }
} }
</style> </style>

View File

@@ -1,61 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
sideBarCollapsed: boolean sideBarCollapsed: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
toggleSideBar: [] toggleSideBar: []
}>() }>()
</script> </script>
<template> <template>
<label <label
class="dui-btn-ghost dui-btn dui-btn-sm dui-btn-circle relative inline-grid cursor-pointer select-none place-content-center" class="dui-btn-ghost dui-btn dui-btn-sm dui-btn-circle relative inline-grid cursor-pointer select-none place-content-center"
>
<input type="checkbox" @click="emit('toggleSideBar')" :checked="!props.sideBarCollapsed" />
<svg
class="swap-off fill-current"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 512 512"
> >
<path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" /> <input type="checkbox" @click="emit('toggleSideBar')" :checked="!props.sideBarCollapsed" />
</svg> <svg
<svg class="swap-off fill-current"
class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" width="24"
width="24" height="24"
height="24" viewBox="0 0 512 512"
viewBox="0 0 512 512" >
> <path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
<polygon </svg>
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49" <svg
/> class="swap-on fill-current"
</svg> xmlns="http://www.w3.org/2000/svg"
</label> width="24"
height="24"
viewBox="0 0 512 512"
>
<polygon
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"
/>
</svg>
</label>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
input { input {
appearance: none; appearance: none;
} }
label > * { label > * {
transition-duration: 0.2s; transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-property: transform, opacity; transition-property: transform, opacity;
grid-column-start: 1; grid-column-start: 1;
grid-row-start: 1; grid-row-start: 1;
} }
input:checked ~ .swap-off { input:checked ~ .swap-off {
opacity: 0; opacity: 0;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
input:checked ~ .swap-on { input:checked ~ .swap-on {
opacity: 1; opacity: 1;
transform: rotate(0deg); transform: rotate(0deg);
} }
.swap-on { .swap-on {
opacity: 0; opacity: 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
</style> </style>

View File

@@ -1,3 +1,3 @@
<template> <template>
<i class="fa-brands fa-staylinked"></i> <i class="fa-brands fa-staylinked"></i>
</template> </template>

View File

@@ -3,32 +3,32 @@ import { OnClickOutside } from '@vueuse/components'
import { vibrate } from '@/composables/useHaptics' import { vibrate } from '@/composables/useHaptics'
</script> </script>
<template> <template>
<OnClickOutside> <OnClickOutside>
<UIDropdown class="search-active-hide"> <UIDropdown class="search-active-hide">
<template #activator> <template #activator>
<UIButton <UIButton
dropdown dropdown
size="sm" size="sm"
variant="outline" variant="outline"
class="topbar-button text-white" class="topbar-button text-white"
@mousedown="vibrate" @mousedown="vibrate"
> >
<i class="fa-fw fa-solid fa-user-gear" /> <i class="fa-fw fa-solid fa-user-gear" />
</UIButton> </UIButton>
</template> </template>
<template #items> <template #items>
<NotesSourceSwitcher /> <NotesSourceSwitcher />
<AccountSettings /> <AccountSettings />
<SignOut /> <SignOut />
</template> </template>
</UIDropdown> </UIDropdown>
</OnClickOutside> </OnClickOutside>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.topbar-button { .topbar-button {
&:focus-within { &:focus-within {
@apply border-white bg-white text-primary; @apply border-white bg-white text-primary;
} }
@apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white; @apply hover:border-white hover:bg-white hover:text-primary focus-visible:outline-white;
} }
</style> </style>

View File

@@ -9,29 +9,29 @@ import FileSaver from 'file-saver'
const verificationEmailSent = ref(false) const verificationEmailSent = ref(false)
const sendVerificationMail = () => { const sendVerificationMail = () => {
if (!user.value) throw Error("User doesn't exist, can't send verification email") if (!user.value) throw Error("User doesn't exist, can't send verification email")
sendEmailVerification(user.value) sendEmailVerification(user.value)
verificationEmailSent.value = true verificationEmailSent.value = true
} }
const exportNotes = async () => { const exportNotes = async () => {
const zip = new JSZip() const zip = new JSZip()
notes.value.forEach((note) => { notes.value.forEach((note) => {
zip.file(`${note.title}-${note.id}.md`, note.content) zip.file(`${note.title}-${note.id}.md`, note.content)
}) })
const blob = await zip.generateAsync({ type: 'blob' }) const blob = await zip.generateAsync({ type: 'blob' })
const currentDate = format(new Date(), 'yyyyMMdd') const currentDate = format(new Date(), 'yyyyMMdd')
FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`) FileSaver.saveAs(blob, `contexted-${user.value?.email}-${currentDate}.zip`)
} }
const showDeleteAccountDialog = ref(false) const showDeleteAccountDialog = ref(false)
const deleteAccount = async () => { const deleteAccount = async () => {
await user.value?.delete() await user.value?.delete()
} }
const showEncryptionDialog = ref(false) const showEncryptionDialog = ref(false)
watch(showEncryptionDialog, () => { watch(showEncryptionDialog, () => {
passphrase.value = '' passphrase.value = ''
}) })
const passphrase = ref('') const passphrase = ref('')
const toggleEncryptionError = ref('') const toggleEncryptionError = ref('')
@@ -39,169 +39,205 @@ const toggleEncryptionError = ref('')
const encryptionEnabled = computed(() => Boolean(encryptionKey.value)) const encryptionEnabled = computed(() => Boolean(encryptionKey.value))
const toggleEncryption = async () => { const toggleEncryption = async () => {
const result = encryptionEnabled.value const result = encryptionEnabled.value
? await disableEncryption(passphrase.value) ? await disableEncryption(passphrase.value)
: await enableEncryption(passphrase.value) : await enableEncryption(passphrase.value)
if (typeof result === 'string') { if (typeof result === 'string') {
toggleEncryptionError.value = result toggleEncryptionError.value = result
} else { } else {
toggleEncryptionError.value = '' toggleEncryptionError.value = ''
showEncryptionDialog.value = false showEncryptionDialog.value = false
} }
} }
</script> </script>
<template> <template>
<UIModal size="lg"> <UIModal size="lg">
<template #activator="{ open }"> <template #activator="{ open }">
<UIDropdownItem @click="open"> <UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-sliders" /> <i class="fa-fw fa-solid fa-sliders" />
Account settings Account settings
</UIDropdownItem> </UIDropdownItem>
</template> </template>
<template #title> <template #title>
<i class="fa-fw fa-solid fa-sliders mr-2" /> <i class="fa-fw fa-solid fa-sliders mr-2" />
Account settings Account settings
</template> </template>
<template #default> <template #default>
<div class="space-y-2"> <div class="space-y-2">
<UICard> <UICard>
<template #title>Account</template> <template #title>Account</template>
<template #default> <template #default>
<template v-if="user?.email"> <template v-if="user?.email">
<div class="w-full flex-row sm:flex"> <div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">E-mail address</div> <div class="font-bold sm:w-4/12">E-mail address</div>
<div>{{ user.email }}</div> <div>{{ user.email }}</div>
</div> </div>
<div class="w-full flex-row sm:flex"> <div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Account status</div> <div class="font-bold sm:w-4/12">Account status</div>
<div class="col-auto"> <div class="col-auto">
<UIBadge :color="user.emailVerified ? 'success' : 'warning'"> <UIBadge :color="user.emailVerified ? 'success' : 'warning'">
{{ user.emailVerified ? 'Verified' : 'Not yet verified' }} {{ user.emailVerified ? 'Verified' : 'Not yet verified' }}
</UIBadge> </UIBadge>
<UIButton <UIButton
size="sm" size="sm"
class="ml-2" class="ml-2"
@click="sendVerificationMail" @click="sendVerificationMail"
v-if="!user.emailVerified" v-if="!user.emailVerified"
:disabled="verificationEmailSent" :disabled="verificationEmailSent"
> >
{{ {{
verificationEmailSent verificationEmailSent
? 'Verification email sent' ? 'Verification email sent'
: 'Request new verification email' : 'Request new verification email'
}} }}
</UIButton> </UIButton>
</div> </div>
</div> </div>
</template> </template>
<div class="w-full flex-row sm:flex"> <div class="w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Account creation date</div> <div class="font-bold sm:w-4/12">Account creation date</div>
<div> <div>
{{ format(Date.parse(user?.metadata?.creationTime || ''), 'dd/MM/yyyy') }} {{
</div> format(
Date.parse(user?.metadata?.creationTime || ''),
'dd/MM/yyyy'
)
}}
</div>
</div>
</template>
</UICard>
<UICard>
<template #title>Notes</template>
<template #default>
<div class="items-top w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Export notes</div>
<UIButton size="sm" @click="exportNotes">
<i class="fa-fw fa-solid fa-file-export mr-2"></i>
Export notes
</UIButton>
</div>
<div class="items-top w-full flex-row sm:flex sm:flex-grow">
<div class="flex-shrink-0 font-bold sm:w-4/12">Delete account</div>
<div>
<UIButton
size="sm"
color="error"
@click="showDeleteAccountDialog = true"
>
<i class="fa-fw fa-solid fa-trash mr-2"></i>
Delete account
</UIButton>
<UIAlert
color="warning"
density="compact"
class="mt-1 space-y-2 text-sm"
v-if="showDeleteAccountDialog"
>
<div>
Are you sure you want to delete your Contexted account? This
action cannot be undone!
</div>
<div class="flex flex-wrap gap-2">
<UIButton
size="sm"
variant="outline"
color="primary"
@click="deleteAccount"
>
Delete account
</UIButton>
<UIButton
size="sm"
variant="outline"
@click="showDeleteAccountDialog = false"
>
Cancel
</UIButton>
</div>
</UIAlert>
</div>
</div>
<div class="items-top w-full flex-row sm:flex">
<div class="flex-shrink-0 font-bold sm:w-4/12">
End-to-end encryption
</div>
<div class="w-full">
<template v-if="!encryptionEnabled">
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key"></i>
Enable end-to-end encryption
</UIButton>
</template>
<template v-else>
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key mr-2"></i>
Disable end-to-end encryption
</UIButton>
</template>
<UIAlert
color="info"
density="compact"
class="text-sm"
v-if="showEncryptionDialog"
>
<div class="w-full space-y-2">
<div>
Enter your passphrase to
{{ encryptionEnabled ? 'disable' : 'enable' }}
encryption
</div>
<UIInputText
size="sm"
type="password"
:color="toggleEncryptionError ? 'error' : 'regular'"
v-model="passphrase"
class="w-full !max-w-full"
/>
<UIAlert
density="compact"
color="error"
v-if="toggleEncryptionError"
>
<i class="fa-solid fa-triangle-exclamation"></i>
{{ toggleEncryptionError }}
</UIAlert>
<div class="flex flex-wrap gap-2">
<UIButton
:disabled="passphrase.length === 0"
size="sm"
variant="outline"
color="primary"
@click="toggleEncryption"
>
{{
encryptionEnabled ? 'Disable' : 'Enable'
}}
encryption
</UIButton>
<UIButton
size="sm"
variant="outline"
@click="showEncryptionDialog = false"
>
Cancel
</UIButton>
</div>
</div>
</UIAlert>
</div>
</div>
</template>
</UICard>
</div> </div>
</template> </template>
</UICard> </UIModal>
<UICard>
<template #title>Notes</template>
<template #default>
<div class="items-top w-full flex-row sm:flex">
<div class="font-bold sm:w-4/12">Export notes</div>
<UIButton size="sm" @click="exportNotes">
<i class="fa-fw fa-solid fa-file-export mr-2"></i>
Export notes
</UIButton>
</div>
<div class="items-top w-full flex-row sm:flex sm:flex-grow">
<div class="flex-shrink-0 font-bold sm:w-4/12">Delete account</div>
<div>
<UIButton size="sm" color="error" @click="showDeleteAccountDialog = true">
<i class="fa-fw fa-solid fa-trash mr-2"></i>
Delete account
</UIButton>
<UIAlert
color="warning"
density="compact"
class="mt-1 space-y-2 text-sm"
v-if="showDeleteAccountDialog"
>
<div>
Are you sure you want to delete your Contexted account? This action cannot be
undone!
</div>
<div class="flex flex-wrap gap-2">
<UIButton size="sm" variant="outline" color="primary" @click="deleteAccount">
Delete account
</UIButton>
<UIButton size="sm" variant="outline" @click="showDeleteAccountDialog = false">
Cancel
</UIButton>
</div>
</UIAlert>
</div>
</div>
<div class="items-top w-full flex-row sm:flex">
<div class="flex-shrink-0 font-bold sm:w-4/12">End-to-end encryption</div>
<div class="w-full">
<template v-if="!encryptionEnabled">
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key"></i>
Enable end-to-end encryption
</UIButton>
</template>
<template v-else>
<UIButton
size="sm"
@click="showEncryptionDialog = true"
v-if="showEncryptionDialog === false"
>
<i class="fa-fw fa-solid fa-key mr-2"></i>
Disable end-to-end encryption
</UIButton>
</template>
<UIAlert color="info" density="compact" class="text-sm" v-if="showEncryptionDialog">
<div class="w-full space-y-2">
<div>
Enter your passphrase to
{{ encryptionEnabled ? 'disable' : 'enable' }}
encryption
</div>
<UIInputText
size="sm"
type="password"
:color="toggleEncryptionError ? 'error' : 'regular'"
v-model="passphrase"
class="w-full !max-w-full"
/>
<UIAlert density="compact" color="error" v-if="toggleEncryptionError">
<i class="fa-solid fa-triangle-exclamation"></i>
{{ toggleEncryptionError }}
</UIAlert>
<div class="flex flex-wrap gap-2">
<UIButton
:disabled="passphrase.length === 0"
size="sm"
variant="outline"
color="primary"
@click="toggleEncryption"
>
{{ encryptionEnabled ? 'Disable' : 'Enable' }} encryption
</UIButton>
<UIButton size="sm" variant="outline" @click="showEncryptionDialog = false">
Cancel
</UIButton>
</div>
</div>
</UIAlert>
</div>
</div>
</template>
</UICard>
</div>
</template>
</UIModal>
</template> </template>

View File

@@ -3,24 +3,24 @@ import { activeNotesSource, availableNotesSources } from '@/composables/useNotes
import { preferredNotesSource } from '@/composables/useSettings' import { preferredNotesSource } from '@/composables/useSettings'
const sourceLabels: { [source: string]: string } = { const sourceLabels: { [source: string]: string } = {
local: 'Switch to local notes', local: 'Switch to local notes',
firebase: 'Switch to cloud notes' firebase: 'Switch to cloud notes'
} }
const blur = () => (document.activeElement as HTMLElement)?.blur() const blur = () => (document.activeElement as HTMLElement)?.blur()
const handleClick = (fn: (...args: any[]) => any) => { const handleClick = (fn: (...args: any[]) => any) => {
blur() blur()
fn() fn()
} }
</script> </script>
<template> <template>
<UIDropdownItem <UIDropdownItem
v-for="source in availableNotesSources.filter((source) => source !== activeNotesSource)" v-for="source in availableNotesSources.filter((source) => source !== activeNotesSource)"
:key="source" :key="source"
@click="handleClick(() => (preferredNotesSource = source))" @click="handleClick(() => (preferredNotesSource = source))"
> >
<i class="fa-fw fa-solid fa-database" /> <i class="fa-fw fa-solid fa-database" />
{{ sourceLabels[source] }} {{ sourceLabels[source] }}
</UIDropdownItem> </UIDropdownItem>
</template> </template>

View File

@@ -4,32 +4,32 @@ import { signOut as firebaseSignOut } from '@/composables/useFirebase'
import { clearEncryptionKeys } from '@/composables/useEncryption' import { clearEncryptionKeys } from '@/composables/useEncryption'
const signOut = async (close: () => Promise<void>) => { const signOut = async (close: () => Promise<void>) => {
await close() await close()
await firebaseSignOut() await firebaseSignOut()
preferredNotesSource.value = null preferredNotesSource.value = null
clearEncryptionKeys() clearEncryptionKeys()
} }
</script> </script>
<template> <template>
<UIModal> <UIModal>
<template #activator="{ open }"> <template #activator="{ open }">
<UIDropdownItem @click="open"> <UIDropdownItem @click="open">
<i class="fa-fw fa-solid fa-right-from-bracket" /> <i class="fa-fw fa-solid fa-right-from-bracket" />
Sign out Sign out
</UIDropdownItem> </UIDropdownItem>
</template> </template>
<template #title> <template #title>
<i class="fa-fw fa-solid fa-right-from-bracket mr-2" /> <i class="fa-fw fa-solid fa-right-from-bracket mr-2" />
Sign out Sign out
</template> </template>
<template #default> <template #default>
<p>Are you sure want to signout?</p> <p>Are you sure want to signout?</p>
<p>Your synchronized notes can't be accessed until you sign-in again.</p> <p>Your synchronized notes can't be accessed until you sign-in again.</p>
</template> </template>
<template #actions="{ close }"> <template #actions="{ close }">
<UIButton size="sm" @click="close">Cancel</UIButton> <UIButton size="sm" @click="close">Cancel</UIButton>
<UIButton size="sm" color="primary" @click="signOut(close)">Sign out</UIButton> <UIButton size="sm" color="primary" @click="signOut(close)">Sign out</UIButton>
</template> </template>
</UIModal> </UIModal>
</template> </template>

View File

@@ -3,105 +3,108 @@ import { getNoteReferences, setActiveNote, findNotes, deleteNote } from '@/compo
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const notesWithReferences = computed(() => { const notesWithReferences = computed(() => {
return findNotes(filter.value).map((note) => ({ return findNotes(filter.value).map((note) => ({
...note, ...note,
references: getNoteReferences(note) references: getNoteReferences(note)
})) }))
}) })
const selectedNotes = ref<{ [key: string]: Boolean }>({}) const selectedNotes = ref<{ [key: string]: Boolean }>({})
const countSelectedNotes = computed<number>( const countSelectedNotes = computed<number>(
() => Object.entries(selectedNotes.value).filter(([, selected]) => Boolean(selected)).length () => Object.entries(selectedNotes.value).filter(([, selected]) => Boolean(selected)).length
) )
const toggleRow = (note: Note) => { const toggleRow = (note: Note) => {
if (!note.isRoot) selectedNotes.value[note.id] = !selectedNotes.value[note.id] if (!note.isRoot) selectedNotes.value[note.id] = !selectedNotes.value[note.id]
} }
const filter = ref<string>('') const filter = ref<string>('')
const deleteSelectedNotes = (closeModal: () => void) => { const deleteSelectedNotes = (closeModal: () => void) => {
closeModal() closeModal()
const notesToDelete = Object.entries(selectedNotes.value) const notesToDelete = Object.entries(selectedNotes.value)
.filter(([, selected]) => Boolean(selected)) .filter(([, selected]) => Boolean(selected))
.map(([id]) => id) .map(([id]) => id)
notesToDelete.forEach((noteId) => deleteNote(noteId)) notesToDelete.forEach((noteId) => deleteNote(noteId))
selectedNotes.value = {} selectedNotes.value = {}
} }
</script> </script>
<template> <template>
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex items-center"> <div class="flex items-center">
<span class="whitespace-nowrap"> <span class="whitespace-nowrap">
{{ notesWithReferences.length }} {{ notesWithReferences.length === 1 ? 'note' : 'notes' }} {{ notesWithReferences.length }}
</span> {{ notesWithReferences.length === 1 ? 'note' : 'notes' }}
<template v-if="countSelectedNotes > 0"> </span>
<span class="mx-1">|</span> <template v-if="countSelectedNotes > 0">
<div class="whitespace-nowrap font-semibold">{{ countSelectedNotes }} selected</div> <span class="mx-1">|</span>
</template> <div class="whitespace-nowrap font-semibold">
</div> {{ countSelectedNotes }} selected
<UIModal v-if="countSelectedNotes > 0"> </div>
<template #activator="{ open }"> </template>
<UIButton size="sm" @click="open">Delete</UIButton> </div>
</template> <UIModal v-if="countSelectedNotes > 0">
<template #default>Are you sure you want to delete the selected notes?</template> <template #activator="{ open }">
<template #actions="{ close }"> <UIButton size="sm" @click="open">Delete</UIButton>
<UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)"> </template>
Delete selected notes <template #default>Are you sure you want to delete the selected notes?</template>
</UIButton> <template #actions="{ close }">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" color="primary" @click="deleteSelectedNotes(close)">
</template> Delete selected notes
</UIModal> </UIButton>
<UIInputText <UIButton size="sm" @click="close">Close</UIButton>
size="sm" </template>
v-model="filter" </UIModal>
placeholder="Start typing to filter" <UIInputText
class="my-1 ml-auto mr-1 max-w-xs flex-grow" size="sm"
></UIInputText> v-model="filter"
placeholder="Start typing to filter"
class="my-1 ml-auto mr-1 max-w-xs flex-grow"
></UIInputText>
</div>
<div class="overflow-x-auto">
<UITable size="md" class="w-full">
<thead>
<tr class="bg-base-200 text-sm text-base-content">
<th class="w-[48px]"></th>
<th>Note title</th>
<th class="w-[75px]">Words</th>
<th class="w-[100px]">References</th>
<th class="w-[150px]">Modified</th>
</tr>
</thead>
<tbody>
<tr
v-for="note in notesWithReferences"
:key="note.id"
class="dui-hover hover:cursor-pointer"
@click="setActiveNote(note.id)"
>
<th @click.stop="toggleRow(note)" class="text-center">
<label>
<UIInputCheckbox
color="primary"
:checked="Boolean(selectedNotes[note.id])"
:disabled="note.isRoot"
></UIInputCheckbox>
</label>
</th>
<td>
<i class="fas fa-fw fa-home mr-1 text-secondary" v-if="note.isRoot" />
{{ note.title }}
</td>
<td>{{ note.wordCount }}</td>
<td>
<UIBadge v-if="note.references.length > 0">
<i data-v-41bbc26f="" class="fas fa-fw fa-sign-out-alt mr-1"></i>
{{ note.references.length }}
</UIBadge>
</td>
<td>{{ formatDate(note.modified) }}</td>
</tr>
</tbody>
</UITable>
</div>
</div> </div>
<div class="overflow-x-auto">
<UITable size="md" class="w-full">
<thead>
<tr class="bg-base-200 text-sm text-base-content">
<th class="w-[48px]"></th>
<th>Note title</th>
<th class="w-[75px]">Words</th>
<th class="w-[100px]">References</th>
<th class="w-[150px]">Modified</th>
</tr>
</thead>
<tbody>
<tr
v-for="note in notesWithReferences"
:key="note.id"
class="dui-hover hover:cursor-pointer"
@click="setActiveNote(note.id)"
>
<th @click.stop="toggleRow(note)" class="text-center">
<label>
<UIInputCheckbox
color="primary"
:checked="Boolean(selectedNotes[note.id])"
:disabled="note.isRoot"
></UIInputCheckbox>
</label>
</th>
<td>
<i class="fas fa-fw fa-home mr-1 text-secondary" v-if="note.isRoot" />
{{ note.title }}
</td>
<td>{{ note.wordCount }}</td>
<td>
<UIBadge v-if="note.references.length > 0">
<i data-v-41bbc26f="" class="fas fa-fw fa-sign-out-alt mr-1"></i>
{{ note.references.length }}
</UIBadge>
</td>
<td>{{ formatDate(note.modified) }}</td>
</tr>
</tbody>
</UITable>
</div>
</div>
</template> </template>

View File

@@ -1,296 +1,303 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
notesRelations, notesRelations,
getNoteById, getNoteById,
activeNote, activeNote,
setActiveNote, setActiveNote,
rootNote rootNote
} from '@/composables/useNotes' } from '@/composables/useNotes'
import cytoscape from 'cytoscape' import cytoscape from 'cytoscape'
import shortid from 'shortid' import shortid from 'shortid'
const renderMindmap = () => { const renderMindmap = () => {
const mindmapCanvas = mindmapElement.value const mindmapCanvas = mindmapElement.value
if (!mindmapCanvas) return if (!mindmapCanvas) return
const style = { const style = {
contextedBlue: '#1e4bc4', contextedBlue: '#1e4bc4',
nodeBackground: '#6c757d', nodeBackground: '#6c757d',
edge: '#ced4da' edge: '#ced4da'
} }
const boundingBox = { const boundingBox = {
x1: 0, x1: 0,
y1: 0, y1: 0,
w: mindmapCanvas.clientWidth, w: mindmapCanvas.clientWidth,
h: mindmapCanvas.clientHeight h: mindmapCanvas.clientHeight
} }
const elements = { const elements = {
nodes: nodes.value, nodes: nodes.value,
edges: [ edges: [
...links.value.map((link) => ({ data: { id: `${link.source}-${link.target}`, ...link } })) ...links.value.map((link) => ({
] data: { id: `${link.source}-${link.target}`, ...link }
} }))
const cy = cytoscape({ ]
container: mindmapCanvas, }
elements, const cy = cytoscape({
autoungrabify: true, container: mindmapCanvas,
autounselectify: true, elements,
layout: { autoungrabify: true,
name: 'cose', autounselectify: true,
layout: {
name: 'cose',
// Called on `layoutready` // Called on `layoutready`
ready: function () {}, ready: function () {},
// Called on `layoutstop` // Called on `layoutstop`
stop: function () {}, stop: function () {},
// Whether to animate while running the layout // Whether to animate while running the layout
// true : Animate continuously as the layout is running // true : Animate continuously as the layout is running
// false : Just show the end result // false : Just show the end result
// 'end' : Animate with the end result, from the initial positions to the end positions // 'end' : Animate with the end result, from the initial positions to the end positions
animate: false, animate: false,
// Easing of the animation for animate:'end' // Easing of the animation for animate:'end'
animationEasing: undefined, animationEasing: undefined,
// The duration of the animation for animate:'end' // The duration of the animation for animate:'end'
animationDuration: undefined, animationDuration: undefined,
// A function that determines whether the node should be animated // A function that determines whether the node should be animated
// All nodes animated by default on animate enabled // All nodes animated by default on animate enabled
// Non-animated nodes are positioned immediately when the layout starts // Non-animated nodes are positioned immediately when the layout starts
// animateFilter: function (node, i) { // animateFilter: function (node, i) {
animateFilter: function () { animateFilter: function () {
return true return true
}, },
// The layout animates only after this many milliseconds for animate:true // The layout animates only after this many milliseconds for animate:true
// (prevents flashing on fast runs) // (prevents flashing on fast runs)
animationThreshold: 250, animationThreshold: 250,
// Number of iterations between consecutive screen positions update // Number of iterations between consecutive screen positions update
refresh: 20, refresh: 20,
// Whether to fit the network view after when done // Whether to fit the network view after when done
fit: true, fit: true,
// Padding on fit // Padding on fit
padding: 30, padding: 30,
// Constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } // Constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
// boundingBox: undefined, // boundingBox: undefined,
boundingBox, boundingBox,
// Excludes the label when calculating node bounding boxes for the layout algorithm // Excludes the label when calculating node bounding boxes for the layout algorithm
nodeDimensionsIncludeLabels: false, nodeDimensionsIncludeLabels: false,
// Randomize the initial positions of the nodes (true) or use existing positions (false) // Randomize the initial positions of the nodes (true) or use existing positions (false)
randomize: false, randomize: false,
// Extra spacing between components in non-compound graphs // Extra spacing between components in non-compound graphs
componentSpacing: 40, componentSpacing: 40,
// Node repulsion (non overlapping) multiplier // Node repulsion (non overlapping) multiplier
// nodeRepulsion: function (node) { // nodeRepulsion: function (node) {
nodeRepulsion: function () { nodeRepulsion: function () {
return 2048 return 2048
}, },
// Node repulsion (overlapping) multiplier // Node repulsion (overlapping) multiplier
nodeOverlap: 4, nodeOverlap: 4,
// Ideal edge (non nested) length // Ideal edge (non nested) length
// idealEdgeLength: function (edge) { // idealEdgeLength: function (edge) {
idealEdgeLength: function () { idealEdgeLength: function () {
return 32 return 32
}, },
// Divisor to compute edge forces // Divisor to compute edge forces
// edgeElasticity: function (edge) { // edgeElasticity: function (edge) {
edgeElasticity: function () { edgeElasticity: function () {
return 32 return 32
}, },
// Nesting factor (multiplier) to compute ideal edge length for nested edges // Nesting factor (multiplier) to compute ideal edge length for nested edges
nestingFactor: 1.2, nestingFactor: 1.2,
// Gravity force (constant) // Gravity force (constant)
gravity: 1, gravity: 1,
// Maximum number of iterations to perform // Maximum number of iterations to perform
numIter: 1000, numIter: 1000,
// Initial temperature (maximum node displacement) // Initial temperature (maximum node displacement)
initialTemp: 1000, initialTemp: 1000,
// Cooling factor (how the temperature is reduced between consecutive iterations // Cooling factor (how the temperature is reduced between consecutive iterations
coolingFactor: 0.99, coolingFactor: 0.99,
// Lower temperature threshold (below this point the layout will end) // Lower temperature threshold (below this point the layout will end)
minTemp: 1.0 minTemp: 1.0
}, },
// userZoomingEnabled: false, // userZoomingEnabled: false,
userPanningEnabled: false, userPanningEnabled: false,
pixelRatio: window.devicePixelRatio ? window.devicePixelRatio * 1.5 : 'auto', pixelRatio: window.devicePixelRatio ? window.devicePixelRatio * 1.5 : 'auto',
style: [ style: [
// the stylesheet for the graph // the stylesheet for the graph
{ {
selector: 'node', selector: 'node',
style: { style: {
'background-color': style.nodeBackground, 'background-color': style.nodeBackground,
label: 'data(title)', label: 'data(title)',
'font-family': 'Source Sans Pro, sans-serif', 'font-family': 'Source Sans Pro, sans-serif',
'font-weight': 400, 'font-weight': 400,
'font-size': '1.5em', 'font-size': '1.5em',
'text-events': 'yes' 'text-events': 'yes'
} }
}, },
{ {
selector: '.current', selector: '.current',
style: { style: {
'background-color': style.contextedBlue 'background-color': style.contextedBlue
} }
}, },
{ {
selector: '.mouseover', selector: '.mouseover',
style: { style: {
'background-color': style.contextedBlue, 'background-color': style.contextedBlue,
color: style.contextedBlue color: style.contextedBlue
} }
}, },
{ {
selector: 'edge', selector: 'edge',
style: { style: {
width: 3, width: 3,
'line-color': style.edge, 'line-color': style.edge,
'target-arrow-color': style.edge, 'target-arrow-color': style.edge,
'target-arrow-shape': 'triangle', 'target-arrow-shape': 'triangle',
'curve-style': 'bezier' 'curve-style': 'bezier'
} }
} }
] ]
}) })
cy.nodes().forEach((node) => { cy.nodes().forEach((node) => {
if (node.data('id') === activeNote.value?.id) node.addClass('current') if (node.data('id') === activeNote.value?.id) node.addClass('current')
}) })
cy.nodes().on('tap', (event) => { cy.nodes().on('tap', (event) => {
setActiveNote(event.target.data('id')) setActiveNote(event.target.data('id'))
}) })
cy.on('mouseover', 'node', (event) => { cy.on('mouseover', 'node', (event) => {
event.target.addClass('mouseover') event.target.addClass('mouseover')
mindmapCanvas.classList.add('mouseover') mindmapCanvas.classList.add('mouseover')
}) })
cy.on('mouseout', 'node', (event) => { cy.on('mouseout', 'node', (event) => {
event.target.removeClass('mouseover') event.target.removeClass('mouseover')
mindmapCanvas.classList.remove('mouseover') mindmapCanvas.classList.remove('mouseover')
}) })
} }
const mindmapElement = ref<HTMLInputElement | null>(null) const mindmapElement = ref<HTMLInputElement | null>(null)
interface Mindmap { interface Mindmap {
id: string id: string
notes: string[] notes: string[]
isRoot: boolean isRoot: boolean
} }
const selectedMindmap = ref<Mindmap>() const selectedMindmap = ref<Mindmap>()
const mindmaps = computed<Mindmap[]>(() => { const mindmaps = computed<Mindmap[]>(() => {
const mindmaps = Object.entries(notesRelations.value).reduce((mindmaps, [noteId, relations]) => { const mindmaps = Object.entries(notesRelations.value).reduce(
const atomicMindmap = [noteId, ...relations.to, ...relations.from] (mindmaps, [noteId, relations]) => {
const indices = mindmaps const atomicMindmap = [noteId, ...relations.to, ...relations.from]
.filter( const indices = mindmaps
(mindmap) => [...mindmap].filter((noteId) => atomicMindmap.includes(noteId)).length > 0 .filter(
) (mindmap) =>
.map((mindmap) => mindmaps.indexOf(mindmap)) [...mindmap].filter((noteId) => atomicMindmap.includes(noteId)).length > 0
if (indices.length > 0) { )
const index = indices[0] .map((mindmap) => mindmaps.indexOf(mindmap))
const currentMindmap = indices.reduce( if (indices.length > 0) {
(mindmap, index) => mindmap.concat(mindmaps[index]), const index = indices[0]
[] as string[] const currentMindmap = indices.reduce(
) (mindmap, index) => mindmap.concat(mindmaps[index]),
indices.forEach((index, i) => { [] as string[]
if (i !== 0) mindmaps.splice(index, 1) )
}) indices.forEach((index, i) => {
mindmaps[index] = [...currentMindmap, ...atomicMindmap].filter( if (i !== 0) mindmaps.splice(index, 1)
(item, index, arr) => arr.indexOf(item) === index })
) mindmaps[index] = [...currentMindmap, ...atomicMindmap].filter(
} else { (item, index, arr) => arr.indexOf(item) === index
mindmaps.push(atomicMindmap) )
} } else {
mindmaps.push(atomicMindmap)
}
return mindmaps
},
[] as string[][]
)
return mindmaps return mindmaps
}, [] as string[][]) .filter((mindmap) => mindmap.length > 1)
return mindmaps .sort((a, b) => b.length - a.length)
.filter((mindmap) => mindmap.length > 1) .sort((a, b) => {
.sort((a, b) => b.length - a.length) return (
.sort((a, b) => { Number(b.includes(rootNote.value?.id || '')) -
return ( Number(a.includes(rootNote.value?.id || ''))
Number(b.includes(rootNote.value?.id || '')) - Number(a.includes(rootNote.value?.id || '')) )
) })
}) .slice(0, 5)
.slice(0, 5) .map((mindmap): Mindmap => {
.map((mindmap): Mindmap => { const isRoot = mindmap.includes(rootNote.value?.id || '')
const isRoot = mindmap.includes(rootNote.value?.id || '') return { id: shortid.generate(), notes: mindmap, isRoot }
return { id: shortid.generate(), notes: mindmap, isRoot } })
})
}) })
watch( watch(
mindmaps, mindmaps,
() => { () => {
if (!selectedMindmap.value) selectedMindmap.value = mindmaps.value[0] if (!selectedMindmap.value) selectedMindmap.value = mindmaps.value[0]
}, },
{ immediate: true } { immediate: true }
) )
watch(selectedMindmap, () => setTimeout(() => renderMindmap(), 0), { immediate: true }) watch(selectedMindmap, () => setTimeout(() => renderMindmap(), 0), { immediate: true })
const nodes = computed(() => { const nodes = computed(() => {
return ( return (
Object.entries(notesRelations.value) Object.entries(notesRelations.value)
// .filter(([, relations]) => relations.to.length > 0) // .filter(([, relations]) => relations.to.length > 0)
.filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId)) .filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId))
.map(([noteId]) => { .map(([noteId]) => {
return { return {
data: { data: {
id: noteId, id: noteId,
title: getNoteById(noteId)?.title title: getNoteById(noteId)?.title
} }
} }
}) })
) )
}) })
const links = computed(() => { const links = computed(() => {
return Object.entries(notesRelations.value) return Object.entries(notesRelations.value)
.filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId)) .filter(([noteId]) => selectedMindmap.value?.notes.includes(noteId))
.filter(([, relations]) => relations.to.length > 0) .filter(([, relations]) => relations.to.length > 0)
.map(([noteId, relations]) => { .map(([noteId, relations]) => {
return relations.to.map((to) => ({ return relations.to.map((to) => ({
source: noteId, source: noteId,
target: to target: to
})) }))
}) })
.reduce((arr, elem) => arr.concat(elem), []) .reduce((arr, elem) => arr.concat(elem), [])
}) })
</script> </script>
<template> <template>
<div class="flex h-full flex-grow flex-col"> <div class="flex h-full flex-grow flex-col">
<div class="flex"> <div class="flex">
<UITabs> <UITabs>
<UITab <UITab
v-for="mindmap in mindmaps" v-for="mindmap in mindmaps"
:key="mindmap.id" :key="mindmap.id"
:active="mindmap.id === selectedMindmap?.id" :active="mindmap.id === selectedMindmap?.id"
@click="selectedMindmap = mindmap" @click="selectedMindmap = mindmap"
> >
<i class="fas fa-fw fa-home root mr-1" v-if="mindmap.isRoot" /> <i class="fas fa-fw fa-home root mr-1" v-if="mindmap.isRoot" />
{{ mindmap.notes.length }} notes {{ mindmap.notes.length }} notes
</UITab> </UITab>
</UITabs> </UITabs>
</div>
<div id="mindmap" ref="mindmapElement" class="h-full"></div>
</div> </div>
<div id="mindmap" ref="mindmapElement" class="h-full"></div>
</div>
</template> </template>
<style scoped> <style scoped>
.mouseover { .mouseover {
@apply hover:cursor-pointer; @apply hover:cursor-pointer;
} }
</style> </style>

View File

@@ -1,70 +1,74 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { import {
activeNote, activeNote,
deleteNote, deleteNote,
rootNote, rootNote,
setRootNote, setRootNote,
setActiveNote, setActiveNote,
getNoteReferences getNoteReferences
} from '@/composables/useNotes' } from '@/composables/useNotes'
const props = defineProps<{ const props = defineProps<{
note: Note note: Note
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
update: [note: Note] update: [note: Note]
}>() }>()
const noteTitle = ref<string>(props.note.title) const noteTitle = ref<string>(props.note.title)
watch(noteTitle, () => { watch(noteTitle, () => {
const updatedNote: Note = { ...props.note, title: noteTitle.value } const updatedNote: Note = { ...props.note, title: noteTitle.value }
emit('update', updatedNote) emit('update', updatedNote)
}) })
const updateNoteContent = (content: string) => { const updateNoteContent = (content: string) => {
const updatedNote: Note = { ...props.note, content } const updatedNote: Note = { ...props.note, content }
emit('update', updatedNote) emit('update', updatedNote)
} }
const references = computed<Note[]>(() => getNoteReferences(props.note)) const references = computed<Note[]>(() => getNoteReferences(props.note))
const handleAction = async (action: 'delete' | 'setRoot', closeModal?: () => Promise<void>) => { const handleAction = async (action: 'delete' | 'setRoot', closeModal?: () => Promise<void>) => {
switch (action) { switch (action) {
case 'delete': case 'delete':
if (closeModal) await closeModal() if (closeModal) await closeModal()
setActiveNote(rootNote.value?.id) setActiveNote(rootNote.value?.id)
deleteNote(props.note.id) deleteNote(props.note.id)
break break
case 'setRoot': case 'setRoot':
setRootNote(props.note.id) setRootNote(props.note.id)
if (closeModal) closeModal() if (closeModal) closeModal()
} }
} }
</script> </script>
<template> <template>
<div class="flex flex-grow flex-col"> <div class="flex flex-grow flex-col">
<NoteToolbar :note="props.note" @execute="handleAction"> <NoteToolbar :note="props.note" @execute="handleAction">
<template #title> <template #title>
<i <i
class="fas fa-fw fa-home mr-2 text-base text-secondary opacity-40" class="fas fa-fw fa-home mr-2 text-base text-secondary opacity-40"
v-if="props.note.isRoot" v-if="props.note.isRoot"
></i> ></i>
<input type="text" class="w-full bg-transparent pb-1 outline-none" v-model="noteTitle" /> <input
</template> type="text"
</NoteToolbar> class="w-full bg-transparent pb-1 outline-none"
<NoteEditor v-model="noteTitle"
class="flex-grow" />
:note="activeNote" </template>
@update="updateNoteContent" </NoteToolbar>
v-if="activeNote" <NoteEditor
></NoteEditor> class="flex-grow"
<NoteReferences :references="references" /> :note="activeNote"
<hr class="my-3" /> @update="updateNoteContent"
<div class="flex text-sm text-secondary"> v-if="activeNote"
<span>{{ note.wordCount }} {{ note.wordCount === 1 ? 'word' : 'words' }}</span> ></NoteEditor>
<span class="ml-auto">Last modified {{ formatDate(note.modified) }}</span> <NoteReferences :references="references" />
<hr class="my-3" />
<div class="flex text-sm text-secondary">
<span>{{ note.wordCount }} {{ note.wordCount === 1 ? 'word' : 'words' }}</span>
<span class="ml-auto">Last modified {{ formatDate(note.modified) }}</span>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,31 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
color?: 'info' | 'success' | 'warning' | 'error' color?: 'info' | 'success' | 'warning' | 'error'
density?: 'regular' | 'compact' density?: 'regular' | 'compact'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
color: 'info', color: 'info',
density: 'regular' density: 'regular'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const colorVariants = { const colorVariants = {
info: 'bg-info', info: 'bg-info',
success: 'bg-success', success: 'bg-success',
warning: 'bg-warning', warning: 'bg-warning',
error: 'bg-error' error: 'bg-error'
} }
const densityVariants = { const densityVariants = {
regular: 'py-4 px-4', regular: 'py-4 px-4',
compact: 'py-2 px-4' compact: 'py-2 px-4'
} }
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
const densityClass = densityVariants[props.density] const densityClass = densityVariants[props.density]
return [colorClass, densityClass] return [colorClass, densityClass]
}) })
</script> </script>
<template> <template>
<div class="rounded-xl shadow-lg" :class="styleClass"> <div class="rounded-xl shadow-lg" :class="styleClass">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View File

@@ -1,41 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
variant?: 'regular' | 'outline' | 'ghost' variant?: 'regular' | 'outline' | 'ghost'
color?: 'regular' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'info' | 'success' | 'warning' | 'error'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
variant: 'regular', variant: 'regular',
color: 'regular' color: 'regular'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-badge-xs', xs: 'dui-badge-xs',
sm: 'dui-badge-sm', sm: 'dui-badge-sm',
md: 'dui-badge-md', md: 'dui-badge-md',
lg: 'dui-badge-lg' lg: 'dui-badge-lg'
} }
const variantVariants = { const variantVariants = {
regular: '', regular: '',
outline: 'dui-badge-outline', outline: 'dui-badge-outline',
ghost: 'dui-badge-ghost' ghost: 'dui-badge-ghost'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
info: 'dui-badge-info', info: 'dui-badge-info',
success: 'dui-badge-success', success: 'dui-badge-success',
warning: 'dui-badge-warning', warning: 'dui-badge-warning',
error: 'dui-badge-error' error: 'dui-badge-error'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const variantClass = variantVariants[props.variant] const variantClass = variantVariants[props.variant]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [sizeClass, variantClass, colorClass] return [sizeClass, variantClass, colorClass]
}) })
</script> </script>
<template> <template>
<span class="dui-badge" :class="styleClass"><slot></slot></span> <span class="dui-badge" :class="styleClass"><slot></slot></span>
</template> </template>

View File

@@ -1,62 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
variant?: 'regular' | 'outline' variant?: 'regular' | 'outline'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
dropdown?: boolean dropdown?: boolean
join?: boolean join?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
variant: 'regular', variant: 'regular',
color: 'regular', color: 'regular',
dropdown: false, dropdown: false,
join: false join: false
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-btn-xs', xs: 'dui-btn-xs',
sm: 'dui-btn-sm', sm: 'dui-btn-sm',
md: 'dui-btn-md', md: 'dui-btn-md',
lg: 'dui-btn-lg' lg: 'dui-btn-lg'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-btn-primary', primary: 'dui-btn-primary',
secondary: 'dui-btn-secondary', secondary: 'dui-btn-secondary',
info: 'dui-btn-info', info: 'dui-btn-info',
success: 'dui-btn-success', success: 'dui-btn-success',
warning: 'dui-btn-warning', warning: 'dui-btn-warning',
error: 'dui-btn-error' error: 'dui-btn-error'
} }
const variantVariants = { const variantVariants = {
regular: '', regular: '',
outline: 'dui-btn-outline' outline: 'dui-btn-outline'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const variantClass = variantVariants[props.variant] const variantClass = variantVariants[props.variant]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
const joinClass = props.join ? 'dui-join-item' : '' const joinClass = props.join ? 'dui-join-item' : ''
return [sizeClass, variantClass, colorClass, joinClass] return [sizeClass, variantClass, colorClass, joinClass]
}) })
</script> </script>
<template> <template>
<label <label
class="dui-btn h-auto px-3 py-2 duration-0" class="dui-btn h-auto px-3 py-2 duration-0"
:class="styleClass" :class="styleClass"
v-if="props.dropdown" v-if="props.dropdown"
tabindex="0" tabindex="0"
> >
<slot></slot> <slot></slot>
</label> </label>
<button <button
type="button" type="button"
class="dui-btn inline-block h-auto max-w-full truncate px-3 py-2 duration-0" class="dui-btn inline-block h-auto max-w-full truncate px-3 py-2 duration-0"
:class="styleClass" :class="styleClass"
v-else v-else
> >
<slot></slot> <slot></slot>
</button> </button>
</template> </template>

View File

@@ -1,3 +1,3 @@
<template> <template>
<div class="dui-join"><slot></slot></div> <div class="dui-join"><slot></slot></div>
</template> </template>

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="dui-card bg-base-100 shadow-sm"> <div class="dui-card bg-base-100 shadow-sm">
<div class="dui-card-body"> <div class="dui-card-body">
<h3 class="dui-card-title" v-if="$slots.title"><slot name="title" /></h3> <h3 class="dui-card-title" v-if="$slots.title"><slot name="title" /></h3>
<slot></slot> <slot></slot>
<div class="dui-card-actions justify-end" v-if="$slots.actions"></div> <div class="dui-card-actions justify-end" v-if="$slots.actions"></div>
</div>
</div> </div>
</div>
</template> </template>

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="dui-dropdown-end dui-dropdown"> <div class="dui-dropdown-end dui-dropdown">
<slot name="activator" tabindex="0"></slot> <slot name="activator" tabindex="0"></slot>
<ul <ul
tabindex="0" tabindex="0"
class="dui-menu-compact dui-dropdown-content dui-menu mt-1 w-52 rounded-box bg-base-100 p-2 text-base-content shadow" class="dui-menu-compact dui-dropdown-content dui-menu mt-1 w-52 rounded-box bg-base-100 p-2 text-base-content shadow"
> >
<slot name="items"></slot> <slot name="items"></slot>
</ul> </ul>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<li class="text-sm"> <li class="text-sm">
<a class="rounded-lg"><slot></slot></a> <a class="rounded-lg"><slot></slot></a>
</li> </li>
</template> </template>

View File

@@ -1,35 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue?: any modelValue?: any
color?: 'regular' | 'primary' color?: 'regular' | 'primary'
checked?: boolean checked?: boolean
disabled?: boolean disabled?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
color: 'regular' color: 'regular'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
const styleClass = computed(() => { const styleClass = computed(() => {
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-checkbox-primary' primary: 'dui-checkbox-primary'
} }
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [colorClass] return [colorClass]
}) })
</script> </script>
<template> <template>
<input <input
type="checkbox" type="checkbox"
class="dui-checkbox dui-checkbox-sm border-secondary" class="dui-checkbox dui-checkbox-sm border-secondary"
:class="styleClass" :class="styleClass"
:checked="props.modelValue || props.checked" :checked="props.modelValue || props.checked"
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)" @change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
:disabled="props.disabled" :disabled="props.disabled"
/> />
</template> </template>

View File

@@ -1,46 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue?: any modelValue?: any
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' color?: 'regular' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md', size: 'md',
color: 'regular' color: 'regular'
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-input-xs', xs: 'dui-input-xs',
sm: 'dui-input-sm', sm: 'dui-input-sm',
md: 'dui-input-md', md: 'dui-input-md',
lg: 'dui-input-lg' lg: 'dui-input-lg'
} }
const colorVariants = { const colorVariants = {
regular: '', regular: '',
primary: 'dui-input-primary', primary: 'dui-input-primary',
secondary: 'dui-input-secondary', secondary: 'dui-input-secondary',
info: 'dui-input-info', info: 'dui-input-info',
success: 'dui-input-success', success: 'dui-input-success',
warning: 'dui-input-warning', warning: 'dui-input-warning',
error: 'dui-input-error' error: 'dui-input-error'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
const colorClass = colorVariants[props.color] const colorClass = colorVariants[props.color]
return [sizeClass, colorClass] return [sizeClass, colorClass]
}) })
</script> </script>
<template> <template>
<input <input
type="text" type="text"
:value="props.modelValue" :value="props.modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement)?.value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement)?.value)"
class="dui-input-bordered dui-input my-1 ml-auto mr-1 max-w-xs flex-grow" class="dui-input-bordered dui-input my-1 ml-auto mr-1 max-w-xs flex-grow"
:class="styleClass" :class="styleClass"
/> />
</template> </template>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
compact?: boolean compact?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const compactClass = props.compact && 'dui-menu-compact' const compactClass = props.compact && 'dui-menu-compact'
return [compactClass] return [compactClass]
}) })
</script> </script>
<template> <template>
<ul tabindex="0" class="dui-menu rounded-md bg-base-100" :class="styleClass"> <ul tabindex="0" class="dui-menu rounded-md bg-base-100" :class="styleClass">
<slot></slot> <slot></slot>
</ul> </ul>
</template> </template>

View File

@@ -1,35 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
title?: boolean title?: boolean
disabled?: boolean disabled?: boolean
active?: boolean active?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const titleClass = props.title const titleClass = props.title
? 'dui-menu-item dui-menu-title !opacity-100 space-x-2 !text-xl font-bold !text-secondary' ? 'dui-menu-item dui-menu-title !opacity-100 space-x-2 !text-xl font-bold !text-secondary'
: '' : ''
return [titleClass] return [titleClass]
}) })
</script> </script>
<template> <template>
<li :class="styleClass"> <li :class="styleClass">
<span class="flex items-center" v-if="props.title"> <span class="flex items-center" v-if="props.title">
<slot></slot> <slot></slot>
</span> </span>
<a <a
class="flex w-full rounded-md" class="flex w-full rounded-md"
:class="{ 'dui-disabled': props.disabled, 'dui-active': props.active }" :class="{ 'dui-disabled': props.disabled, 'dui-active': props.active }"
v-else v-else
> >
<slot></slot> <slot></slot>
</a> </a>
</li> </li>
</template> </template>
<style scoped> <style scoped>
.dui-active { .dui-active {
@apply bg-primary; @apply bg-primary;
} }
</style> </style>

View File

@@ -3,28 +3,28 @@ import { onClickOutside } from '@vueuse/core'
import { vibrate } from '@/composables/useHaptics' import { vibrate } from '@/composables/useHaptics'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
open?: boolean open?: boolean
persistent?: boolean persistent?: boolean
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
icon?: string icon?: string
}>(), }>(),
{ {
open: false, open: false,
persistent: false, persistent: false,
size: 'md' size: 'md'
} }
) )
const show = ref<boolean>(false) const show = ref<boolean>(false)
watch( watch(
() => props.open, () => props.open,
() => { () => {
if (show.value) vibrate() if (show.value) vibrate()
show.value = props.open show.value = props.open
}, },
{ immediate: true } { immediate: true }
) )
const modal = ref<HTMLElement | null>(null) const modal = ref<HTMLElement | null>(null)
@@ -32,10 +32,10 @@ const modalBox = ref(null)
const open = () => (show.value = true) const open = () => (show.value = true)
const close = (): Promise<void> => { const close = (): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
modal.value?.addEventListener('transitionend', () => resolve()) modal.value?.addEventListener('transitionend', () => resolve())
show.value = false show.value = false
}) })
} }
const slotProps = { open, close } const slotProps = { open, close }
@@ -43,48 +43,48 @@ const slotProps = { open, close }
if (!props.persistent) onClickOutside(modalBox, () => close()) if (!props.persistent) onClickOutside(modalBox, () => close())
const onEnter = (el: Element, done: () => void): void => { const onEnter = (el: Element, done: () => void): void => {
setTimeout(() => { setTimeout(() => {
el.classList.add('dui-modal-open') el.classList.add('dui-modal-open')
done() done()
}) })
} }
const onLeave = (el: Element, done: () => void): void => { const onLeave = (el: Element, done: () => void): void => {
el.classList.remove('dui-modal-open') el.classList.remove('dui-modal-open')
el.addEventListener('transitionend', () => done()) el.addEventListener('transitionend', () => done())
} }
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
sm: 'max-w-xs', sm: 'max-w-xs',
md: 'max-w-md', md: 'max-w-md',
lg: 'max-w-2xl' lg: 'max-w-2xl'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
return [sizeClass] return [sizeClass]
}) })
defineExpose({ open, close }) defineExpose({ open, close })
</script> </script>
<template> <template>
<slot name="activator" v-bind="slotProps"></slot> <slot name="activator" v-bind="slotProps"></slot>
<Teleport to="body"> <Teleport to="body">
<Transition @enter="onEnter" @leave="onLeave" appear> <Transition @enter="onEnter" @leave="onLeave" appear>
<div class="dui-modal bg-neutral-800 bg-opacity-60" v-if="show" ref="modal"> <div class="dui-modal bg-neutral-800 bg-opacity-60" v-if="show" ref="modal">
<div class="dui-modal-box" :class="styleClass" ref="modalBox"> <div class="dui-modal-box" :class="styleClass" ref="modalBox">
<h3 class="mb-4 flex items-center text-xl font-bold" v-if="$slots.title"> <h3 class="mb-4 flex items-center text-xl font-bold" v-if="$slots.title">
<slot name="title" /> <slot name="title" />
</h3> </h3>
<div> <div>
<slot v-bind="slotProps" /> <slot v-bind="slotProps" />
</div> </div>
<div class="dui-modal-action mt-4"> <div class="dui-modal-action mt-4">
<slot name="actions" v-bind="slotProps"> <slot name="actions" v-bind="slotProps">
<UIButton size="sm" @click="close">Close</UIButton> <UIButton size="sm" @click="close">Close</UIButton>
</slot> </slot>
</div> </div>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
active?: boolean active?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const styleClass = computed(() => { const styleClass = computed(() => {
const activeClass = props.active && 'dui-tab-active font-bold !border-primary text-primary' const activeClass = props.active && 'dui-tab-active font-bold !border-primary text-primary'
return [activeClass] return [activeClass]
}) })
</script> </script>
<template> <template>
<a class="dui-tab-bordered dui-tab hover:font-bold hover:text-primary" :class="styleClass"> <a class="dui-tab-bordered dui-tab hover:font-bold hover:text-primary" :class="styleClass">
<slot></slot> <slot></slot>
</a> </a>
</template> </template>

View File

@@ -1,23 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
size?: 'xs' | 'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg'
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 'md' size: 'md'
}) })
const styleClass = computed(() => { const styleClass = computed(() => {
const sizeVariants = { const sizeVariants = {
xs: 'dui-table-xs', xs: 'dui-table-xs',
sm: 'dui-table-sm', sm: 'dui-table-sm',
md: 'dui-table-md', md: 'dui-table-md',
lg: 'dui-table-lg' lg: 'dui-table-lg'
} }
const sizeClass = sizeVariants[props.size] const sizeClass = sizeVariants[props.size]
return [sizeClass] return [sizeClass]
}) })
</script> </script>
<template> <template>
<table class="dui-table" :class="styleClass"><slot></slot></table> <table class="dui-table" :class="styleClass"><slot></slot></table>
</template> </template>

View File

@@ -1,3 +1,3 @@
<template> <template>
<div class="dui-tabs dui-tabs-boxed dui-tabs-md"><slot></slot></div> <div class="dui-tabs dui-tabs-boxed dui-tabs-md"><slot></slot></div>
</template> </template>

View File

@@ -5,150 +5,156 @@ import { preferredNotesSource } from '@/composables/useSettings'
import { activeNotesSource, syncNotesToFirebase, baseNotes } from '@/composables/useNotes' import { activeNotesSource, syncNotesToFirebase, baseNotes } from '@/composables/useNotes'
function getClientKeysFromLocalStorage(): { [uid: string]: string } { function getClientKeysFromLocalStorage(): { [uid: string]: string } {
try { try {
return JSON.parse(localStorage.getItem('clientKeys') || '{}') return JSON.parse(localStorage.getItem('clientKeys') || '{}')
} catch (e) { } catch (e) {
return {} return {}
} }
} }
export const clientKey = ref<ClientKey>() export const clientKey = ref<ClientKey>()
export const getClientKey = () => { export const getClientKey = () => {
if (!user.value) return if (!user.value) return
const clientKeys = getClientKeysFromLocalStorage() const clientKeys = getClientKeysFromLocalStorage()
clientKey.value = clientKeys[user.value?.uid] clientKey.value = clientKeys[user.value?.uid]
} }
export const setClientKey = (passphrase: string) => { export const setClientKey = (passphrase: string) => {
const calculatedClientKey = calculateClientKey(passphrase) const calculatedClientKey = calculateClientKey(passphrase)
const verified = verifyClientKey(calculatedClientKey) const verified = verifyClientKey(calculatedClientKey)
if (!user.value || !verified) return false if (!user.value || !verified) return false
const clientKeys = getClientKeysFromLocalStorage() const clientKeys = getClientKeysFromLocalStorage()
clientKeys[user.value.uid] = calculatedClientKey clientKeys[user.value.uid] = calculatedClientKey
localStorage.setItem('clientKeys', JSON.stringify(clientKeys)) localStorage.setItem('clientKeys', JSON.stringify(clientKeys))
clientKey.value = calculatedClientKey clientKey.value = calculatedClientKey
getEncryptionKey() getEncryptionKey()
return true return true
} }
export const verifyClientKey = (clientKey: ClientKey) => { export const verifyClientKey = (clientKey: ClientKey) => {
try { try {
if (!encryptedEncryptionKey.value) throw new Error('Encryption key is null') if (!encryptedEncryptionKey.value) throw new Error('Encryption key is null')
if (!clientKey) throw new Error('Client key is null') if (!clientKey) throw new Error('Client key is null')
decrypt(encryptedEncryptionKey.value, clientKey) decrypt(encryptedEncryptionKey.value, clientKey)
return true return true
} catch (e) { } catch (e) {
console.log(e) console.log(e)
return false return false
} }
} }
const removeClientKey = () => { const removeClientKey = () => {
if (!user.value) return if (!user.value) return
const clientKeys = structuredClone(getClientKeysFromLocalStorage()) const clientKeys = structuredClone(getClientKeysFromLocalStorage())
delete clientKeys[user.value?.uid] delete clientKeys[user.value?.uid]
localStorage.setItem('clientKeys', JSON.stringify(clientKeys)) localStorage.setItem('clientKeys', JSON.stringify(clientKeys))
} }
const encryptedEncryptionKey = ref<EncryptedEncryptionKey | null>() const encryptedEncryptionKey = ref<EncryptedEncryptionKey | null>()
async function getEncryptedEncryptionKey(): Promise<EncryptedEncryptionKey | void> { async function getEncryptedEncryptionKey(): Promise<EncryptedEncryptionKey | void> {
if (!user.value || !db.value) return if (!user.value || !db.value) return
const data = (await getDoc(doc(db.value, 'encryptionKeys', user.value.uid))).data() const data = (await getDoc(doc(db.value, 'encryptionKeys', user.value.uid))).data()
return data?.key return data?.key
} }
async function setEncryptedEncryptionKey(encryptedEncryptionKey: EncryptedEncryptionKey | null) { async function setEncryptedEncryptionKey(encryptedEncryptionKey: EncryptedEncryptionKey | null) {
if (!user.value || !db.value) return if (!user.value || !db.value) return
const docRef = doc(db.value, 'encryptionKeys', user.value.uid) const docRef = doc(db.value, 'encryptionKeys', user.value.uid)
await setDoc(docRef, { key: encryptedEncryptionKey }) await setDoc(docRef, { key: encryptedEncryptionKey })
} }
export const encryptionKey = ref<EncryptionKey | null>() export const encryptionKey = ref<EncryptionKey | null>()
export async function getEncryptionKey() { export async function getEncryptionKey() {
encryptedEncryptionKey.value = (await getEncryptedEncryptionKey()) || undefined encryptedEncryptionKey.value = (await getEncryptedEncryptionKey()) || undefined
if (encryptedEncryptionKey.value && clientKey.value) { if (encryptedEncryptionKey.value && clientKey.value) {
encryptionKey.value = decrypt(encryptedEncryptionKey.value, clientKey.value) encryptionKey.value = decrypt(encryptedEncryptionKey.value, clientKey.value)
} else if (!encryptedEncryptionKey.value) { } else if (!encryptedEncryptionKey.value) {
encryptionKey.value = null encryptionKey.value = null
} else { } else {
encryptionKey.value = undefined encryptionKey.value = undefined
} }
} }
export const passphraseRequired = computed(() => { export const passphraseRequired = computed(() => {
return Boolean(encryptedEncryptionKey.value && !clientKey.value) return Boolean(encryptedEncryptionKey.value && !clientKey.value)
}) })
const decryptNote = (note: BaseNote, key: EncryptionKey) => { const decryptNote = (note: BaseNote, key: EncryptionKey) => {
try { try {
return { return {
...note, ...note,
title: decrypt(note.title, key), title: decrypt(note.title, key),
content: decrypt(note.content, key) content: decrypt(note.content, key)
}
} catch (error: any) {
console.error(error)
return note
} }
} catch (error: any) {
console.error(error)
return note
}
} }
export const decryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => { export const decryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => {
const decryptedNotes = Object.fromEntries( const decryptedNotes = Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => [noteId, { ...decryptNote(note, encryptionKey) }]) Object.entries(notes).map(([noteId, note]) => [
) noteId,
return decryptedNotes { ...decryptNote(note, encryptionKey) }
])
)
return decryptedNotes
} }
const encryptNote = (note: BaseNote, key: EncryptionKey) => { const encryptNote = (note: BaseNote, key: EncryptionKey) => {
return { return {
...note, ...note,
title: encrypt(note.title, key), title: encrypt(note.title, key),
content: encrypt(note.content, key) content: encrypt(note.content, key)
} }
} }
export const encryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => { export const encryptNotes = (notes: BaseNotes, encryptionKey: EncryptionKey) => {
const encryptedNotes = Object.fromEntries( const encryptedNotes = Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => [noteId, { ...encryptNote(note, encryptionKey) }]) Object.entries(notes).map(([noteId, note]) => [
) noteId,
return encryptedNotes { ...encryptNote(note, encryptionKey) }
])
)
return encryptedNotes
} }
export const verifyPassphrase = (passphrase: string) => { export const verifyPassphrase = (passphrase: string) => {
const calculatedClientKey = calculateClientKey(passphrase) const calculatedClientKey = calculateClientKey(passphrase)
return calculatedClientKey === clientKey.value return calculatedClientKey === clientKey.value
} }
export const disableEncryption = async (passphrase: string) => { export const disableEncryption = async (passphrase: string) => {
if (!encryptionKey.value) return "Encryption key doesn't exist." if (!encryptionKey.value) return "Encryption key doesn't exist."
if (!verifyPassphrase(passphrase)) return 'Passphrase is incorrect.' if (!verifyPassphrase(passphrase)) return 'Passphrase is incorrect.'
preferredNotesSource.value = 'firebase' preferredNotesSource.value = 'firebase'
if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.') if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.')
await setEncryptedEncryptionKey(null) await setEncryptedEncryptionKey(null)
encryptedEncryptionKey.value = null encryptedEncryptionKey.value = null
encryptionKey.value = undefined encryptionKey.value = undefined
removeClientKey() removeClientKey()
await syncNotesToFirebase(baseNotes.value) await syncNotesToFirebase(baseNotes.value)
getEncryptionKey() getEncryptionKey()
} }
export const enableEncryption = async (passphrase: string) => { export const enableEncryption = async (passphrase: string) => {
preferredNotesSource.value = 'firebase' preferredNotesSource.value = 'firebase'
if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.') if (activeNotesSource.value !== 'firebase') throw Error('Something went wrong.')
const candidateEncryptionKey = generateEncryptionKey() const candidateEncryptionKey = generateEncryptionKey()
const candidateClientKey = calculateClientKey(passphrase) const candidateClientKey = calculateClientKey(passphrase)
const candidateEncryptedEncryptionKey = encrypt(candidateEncryptionKey, candidateClientKey) const candidateEncryptedEncryptionKey = encrypt(candidateEncryptionKey, candidateClientKey)
await setEncryptedEncryptionKey(candidateEncryptedEncryptionKey) await setEncryptedEncryptionKey(candidateEncryptedEncryptionKey)
encryptedEncryptionKey.value = candidateEncryptedEncryptionKey encryptedEncryptionKey.value = candidateEncryptedEncryptionKey
encryptionKey.value = candidateEncryptionKey encryptionKey.value = candidateEncryptionKey
setClientKey(passphrase) setClientKey(passphrase)
await syncNotesToFirebase(baseNotes.value) await syncNotesToFirebase(baseNotes.value)
} }
export const clearEncryptionKeys = () => { export const clearEncryptionKeys = () => {
encryptionKey.value = undefined encryptionKey.value = undefined
encryptedEncryptionKey.value = undefined encryptedEncryptionKey.value = undefined
} }

View File

@@ -5,16 +5,16 @@ import { Capacitor } from '@capacitor/core'
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
import { import {
initializeFirestore, initializeFirestore,
persistentLocalCache, persistentLocalCache,
persistentMultipleTabManager persistentMultipleTabManager
} from 'firebase/firestore' } from 'firebase/firestore'
import { import {
getAuth, getAuth,
indexedDBLocalPersistence, indexedDBLocalPersistence,
initializeAuth, initializeAuth,
onAuthStateChanged, onAuthStateChanged,
signOut as firebaseSignOut signOut as firebaseSignOut
} from 'firebase/auth' } from 'firebase/auth'
import { type FirebaseApp } from 'firebase/app' import { type FirebaseApp } from 'firebase/app'
import { type User } from '@firebase/auth' import { type User } from '@firebase/auth'
@@ -27,14 +27,14 @@ import type { Firestore } from 'firebase/firestore'
// Your web app's Firebase configuration // Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional // For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = { const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY, apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL, databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
} }
export const user = ref<User | null>() export const user = ref<User | null>()
@@ -46,26 +46,26 @@ export const signOut = async () => firebaseSignOut(getAuth())
export const db = ref<Firestore>() export const db = ref<Firestore>()
const getFirebaseAuth = async (app: FirebaseApp) => { const getFirebaseAuth = async (app: FirebaseApp) => {
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
return initializeAuth(app, { return initializeAuth(app, {
persistence: indexedDBLocalPersistence persistence: indexedDBLocalPersistence
}) })
} else { } else {
return getAuth() return getAuth()
} }
} }
// Initialize Firebase // Initialize Firebase
export const initializeFirebase = async () => { export const initializeFirebase = async () => {
const app = initializeApp(firebaseConfig) const app = initializeApp(firebaseConfig)
const auth = await getFirebaseAuth(app) const auth = await getFirebaseAuth(app)
onAuthStateChanged(auth, (firebaseUser) => { onAuthStateChanged(auth, (firebaseUser) => {
console.log('auth state changed', firebaseUser) console.log('auth state changed', firebaseUser)
user.value = firebaseUser user.value = firebaseUser
})
db.value = markRaw(
initializeFirestore(app, {
localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() })
}) })
) db.value = markRaw(
initializeFirestore(app, {
localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() })
})
)
} }

View File

@@ -11,14 +11,14 @@ import { preferredNotesSource } from '@/composables/useSettings'
import { vibrate } from '@/composables/useHaptics' import { vibrate } from '@/composables/useHaptics'
export const notesSources = computed(() => ({ export const notesSources = computed(() => ({
local: true, local: true,
firebase: initialized.value && user.value firebase: initialized.value && user.value
})) }))
export const availableNotesSources = computed<notesSourceValues[]>(() => export const availableNotesSources = computed<notesSourceValues[]>(() =>
Object.entries(notesSources.value) Object.entries(notesSources.value)
.filter(([, enabled]) => enabled) .filter(([, enabled]) => enabled)
.map(([source]) => source as notesSourceValues) .map(([source]) => source as notesSourceValues)
) )
export type notesSourceValues = keyof typeof notesSources.value export type notesSourceValues = keyof typeof notesSources.value
@@ -26,249 +26,255 @@ export type notesSourceValues = keyof typeof notesSources.value
export const activeNotesSource = ref<notesSourceValues | null>(null) export const activeNotesSource = ref<notesSourceValues | null>(null)
watchEffect(() => { watchEffect(() => {
const getSource = (): notesSourceValues | null => { const getSource = (): notesSourceValues | null => {
if (!initialized.value) return null if (!initialized.value) return null
if ( if (
preferredNotesSource.value && preferredNotesSource.value &&
availableNotesSources.value.includes(preferredNotesSource.value) availableNotesSources.value.includes(preferredNotesSource.value)
) { ) {
return preferredNotesSource.value return preferredNotesSource.value
} else { } else {
return user.value ? 'firebase' : 'local' return user.value ? 'firebase' : 'local'
}
} }
} activeNotesSource.value = getSource()
activeNotesSource.value = getSource()
}) })
export const baseNotes = ref<BaseNotes>({}) export const baseNotes = ref<BaseNotes>({})
const syncNotesLocal = (notes: BaseNotes) => { const syncNotesLocal = (notes: BaseNotes) => {
localStorage.setItem('notes', JSON.stringify(notes)) localStorage.setItem('notes', JSON.stringify(notes))
} }
// export const syncNotesToFirebase = async (newNotes: BaseNotes, oldNotes?: BaseNotes) => { // export const syncNotesToFirebase = async (newNotes: BaseNotes, oldNotes?: BaseNotes) => {
export const syncNotesToFirebase = async (baseNotes: BaseNotes) => { export const syncNotesToFirebase = async (baseNotes: BaseNotes) => {
if (!db.value) throw Error("Database undefined, can't sync to Firebase") if (!db.value) throw Error("Database undefined, can't sync to Firebase")
if (!user.value) throw Error("User undefined, can't sync to Firebase") if (!user.value) throw Error("User undefined, can't sync to Firebase")
const notes = encryptionKey.value ? encryptNotes(baseNotes, encryptionKey.value) : baseNotes const notes = encryptionKey.value ? encryptNotes(baseNotes, encryptionKey.value) : baseNotes
try { try {
const docRef = doc(db.value, 'pages', user.value.uid) const docRef = doc(db.value, 'pages', user.value.uid)
// if (oldNotes) { // if (oldNotes) {
// const notesToDelete = Object.keys(oldNotes).filter((x) => !Object.keys(newNotes).includes(x)) // const notesToDelete = Object.keys(oldNotes).filter((x) => !Object.keys(newNotes).includes(x))
// await Promise.all( // await Promise.all(
// notesToDelete.map((noteId: string) => { // notesToDelete.map((noteId: string) => {
// return updateDoc(docRef, { [noteId]: deleteField() }) // return updateDoc(docRef, { [noteId]: deleteField() })
// }) // })
// ) // )
// } // }
await setDoc(docRef, notes) await setDoc(docRef, notes)
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
} }
} }
watch( watch(
baseNotes, baseNotes,
async () => { async () => {
if (!activeNotesSource.value || Object.keys(baseNotes.value).length === 0) return if (!activeNotesSource.value || Object.keys(baseNotes.value).length === 0) return
if (activeNotesSource.value === 'local') { if (activeNotesSource.value === 'local') {
syncNotesLocal(baseNotes.value) syncNotesLocal(baseNotes.value)
} else if (activeNotesSource.value === 'firebase') { } else if (activeNotesSource.value === 'firebase') {
syncNotesToFirebase(baseNotes.value) syncNotesToFirebase(baseNotes.value)
} }
}, },
{ deep: true } { deep: true }
) )
export const notes = computed<Note[]>(() => { export const notes = computed<Note[]>(() => {
return Object.entries(baseNotes.value) return Object.entries(baseNotes.value)
.map(([, note]) => ({ .map(([, note]) => ({
...note, ...note,
wordCount: note.content.split(' ').filter((word) => word.length > 0).length wordCount: note.content.split(' ').filter((word) => word.length > 0).length
})) }))
.sort((a, b) => b.modified - a.modified) as Note[] .sort((a, b) => b.modified - a.modified) as Note[]
}) })
watch(notes, () => { watch(notes, () => {
if (notes.value.length > 0 && !activeNote.value && activeViewMode.value.name === 'Note') if (notes.value.length > 0 && !activeNote.value && activeViewMode.value.name === 'Note')
setActiveNote(rootNote.value?.id, false) setActiveNote(rootNote.value?.id, false)
}) })
const activeNoteId = ref<string>() const activeNoteId = ref<string>()
export const activeNote = computed(() => notes.value.find((note) => note.id === activeNoteId.value)) export const activeNote = computed(() => notes.value.find((note) => note.id === activeNoteId.value))
watch(activeNote, () => { watch(activeNote, () => {
if (activeNote.value) { if (activeNote.value) {
useTitle(`${activeNote.value.title} | Contexted`) useTitle(`${activeNote.value.title} | Contexted`)
} }
}) })
export const setActiveNote = (noteId: string | undefined, haptic: boolean = true) => { export const setActiveNote = (noteId: string | undefined, haptic: boolean = true) => {
if (noteId) { if (noteId) {
activeNoteId.value = noteId activeNoteId.value = noteId
activeViewMode.value = viewModes.find((mode) => mode.name === 'Note') || viewModes[0] activeViewMode.value = viewModes.find((mode) => mode.name === 'Note') || viewModes[0]
if (haptic) vibrate() if (haptic) vibrate()
} }
} }
export const rootNote = computed<Note | undefined>(() => { export const rootNote = computed<Note | undefined>(() => {
const rootNote = notes.value.find((note: Note) => note.isRoot) const rootNote = notes.value.find((note: Note) => note.isRoot)
return rootNote return rootNote
}) })
export const setRootNote = (noteId: string) => { export const setRootNote = (noteId: string) => {
if (rootNote.value) { if (rootNote.value) {
const updatedRootNote = { ...baseNotes.value[rootNote.value.id], isRoot: false } const updatedRootNote = { ...baseNotes.value[rootNote.value.id], isRoot: false }
updateNote(updatedRootNote.id, updatedRootNote) updateNote(updatedRootNote.id, updatedRootNote)
} }
const note = { ...baseNotes.value[noteId], isRoot: true } const note = { ...baseNotes.value[noteId], isRoot: true }
updateNote(noteId, note) updateNote(noteId, note)
setActiveNote(noteId, false) setActiveNote(noteId, false)
} }
export const insertDefaultNotes = (defaultNotes: BaseNote[]) => { export const insertDefaultNotes = (defaultNotes: BaseNote[]) => {
defaultNotes.forEach((defaultNote) => { defaultNotes.forEach((defaultNote) => {
baseNotes.value[defaultNote.id] = defaultNote baseNotes.value[defaultNote.id] = defaultNote
}) })
} }
export const getNoteById = (noteId: string) => { export const getNoteById = (noteId: string) => {
return notes.value.find((note) => note.id === noteId) return notes.value.find((note) => note.id === noteId)
} }
export const getNoteByTitle = (title: string) => { export const getNoteByTitle = (title: string) => {
return notes.value.find((note) => note.title === title) return notes.value.find((note) => note.title === title)
} }
export const findNotesByByTitle = (title: string) => { export const findNotesByByTitle = (title: string) => {
const titleLowerCase = title.toLowerCase() const titleLowerCase = title.toLowerCase()
return notes.value.filter((note) => note.title.toLowerCase().includes(titleLowerCase)) return notes.value.filter((note) => note.title.toLowerCase().includes(titleLowerCase))
} }
export const findNotes = (query: string): Note[] => { export const findNotes = (query: string): Note[] => {
const removeMdFromText = (mdText: string): string => { const removeMdFromText = (mdText: string): string => {
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = mdToHtml(mdText) div.innerHTML = mdToHtml(mdText)
const textWithoutMd = div.textContent || div.innerText || '' const textWithoutMd = div.textContent || div.innerText || ''
return textWithoutMd return textWithoutMd
} }
return notes.value.filter((note) => { return notes.value.filter((note) => {
const matchTitle = note.title.toLowerCase().includes(query.toLowerCase()) const matchTitle = note.title.toLowerCase().includes(query.toLowerCase())
const matchContent = removeMdFromText(note.content).toLowerCase().includes(query.toLowerCase()) const matchContent = removeMdFromText(note.content)
return matchTitle || matchContent .toLowerCase()
}) .includes(query.toLowerCase())
return matchTitle || matchContent
})
} }
export const updateNote = (noteId: string, note: BaseNote) => { export const updateNote = (noteId: string, note: BaseNote) => {
const updatedNote: BaseNote = { const updatedNote: BaseNote = {
...note, ...note,
modified: new Date().getTime() modified: new Date().getTime()
} }
baseNotes.value[noteId] = updatedNote baseNotes.value[noteId] = updatedNote
} }
export const addNote = (title: string, content: string, goToNote: boolean = false) => { export const addNote = (title: string, content: string, goToNote: boolean = false) => {
const id = shortid.generate() const id = shortid.generate()
const newNote: BaseNote = { const newNote: BaseNote = {
id, id,
title, title,
content, content,
isRoot: false, isRoot: false,
created: new Date().getTime(), created: new Date().getTime(),
modified: new Date().getTime() modified: new Date().getTime()
} }
baseNotes.value[id] = newNote baseNotes.value[id] = newNote
if (goToNote) setActiveNote(id) if (goToNote) setActiveNote(id)
return newNote return newNote
} }
export const deleteNote = (noteId: string) => { export const deleteNote = (noteId: string) => {
const baseNotesClone: BaseNotes = structuredClone(toRaw(baseNotes.value)) const baseNotesClone: BaseNotes = structuredClone(toRaw(baseNotes.value))
delete baseNotesClone[noteId] delete baseNotesClone[noteId]
baseNotes.value = baseNotesClone baseNotes.value = baseNotesClone
} }
const getNoteLinksByNoteId = (noteId: string): string[] => { const getNoteLinksByNoteId = (noteId: string): string[] => {
const note = baseNotes.value[noteId] const note = baseNotes.value[noteId]
const regex = /\[\[(.*?)\]\]/g const regex = /\[\[(.*?)\]\]/g
const links = getAllMatches(regex, note.content || '') const links = getAllMatches(regex, note.content || '')
.map((to) => notes.value.find((note) => note.title === to[1])?.id || '') .map((to) => notes.value.find((note) => note.title === to[1])?.id || '')
.filter((noteId) => Object.keys(baseNotes.value).includes(noteId)) .filter((noteId) => Object.keys(baseNotes.value).includes(noteId))
return [...links] return [...links]
} }
export const notesRelations = computed(() => { export const notesRelations = computed(() => {
const noteIds = Object.keys(baseNotes.value) const noteIds = Object.keys(baseNotes.value)
const relations = noteIds const relations = noteIds
.filter((id) => id !== undefined) .filter((id) => id !== undefined)
.map((id) => { .map((id) => {
const to = getNoteLinksByNoteId(id) const to = getNoteLinksByNoteId(id)
return { id, to } return { id, to }
}) })
.map((noteRelations, _, notesRelations): NoteRelations => { .map((noteRelations, _, notesRelations): NoteRelations => {
const from = [...notesRelations] const from = [...notesRelations]
.map((noteRelation) => .map((noteRelation) =>
noteRelation.to.filter((toId) => toId === noteRelations.id).map(() => noteRelation.id) noteRelation.to
) .filter((toId) => toId === noteRelations.id)
.reduce((arr, elem) => arr.concat(elem), []) .map(() => noteRelation.id)
.filter((value, index, self) => self.indexOf(value) === index) )
return { .reduce((arr, elem) => arr.concat(elem), [])
id: noteRelations.id, .filter((value, index, self) => self.indexOf(value) === index)
to: noteRelations.to, return {
from id: noteRelations.id,
} to: noteRelations.to,
}) from
.reduce((notes, { id, to, from }) => { }
notes[id] = { to, from } })
return notes .reduce((notes, { id, to, from }) => {
}, {} as NotesRelations) notes[id] = { to, from }
return relations return notes
}, {} as NotesRelations)
return relations
}) })
export function getNoteReferences(note: Note) { export function getNoteReferences(note: Note) {
const relations = notesRelations.value[note.id] const relations = notesRelations.value[note.id]
return relations return relations
? (relations.from || []) ? (relations.from || [])
.map((noteId) => { .map((noteId) => {
return notes.value.find((note) => note.id === noteId) return notes.value.find((note) => note.id === noteId)
}) })
.filter((note): note is Note => note !== undefined) .filter((note): note is Note => note !== undefined)
: [] : []
} }
const parseBaseNotes = (notes: BaseNotes): BaseNotes => { const parseBaseNotes = (notes: BaseNotes): BaseNotes => {
return Object.fromEntries( return Object.fromEntries(
Object.entries(notes).map(([noteId, note]) => { Object.entries(notes).map(([noteId, note]) => {
return [ return [
noteId, noteId,
{ {
id: noteId, id: noteId,
title: note.title, title: note.title,
content: note.content, content: note.content,
isRoot: Boolean(note.isRoot), isRoot: Boolean(note.isRoot),
created: note.created || note.modified || new Date().getTime(), created: note.created || note.modified || new Date().getTime(),
modified: note.modified || note.created || new Date().getTime() modified: note.modified || note.created || new Date().getTime()
} }
] ]
}) })
) )
} }
export const getNotes = async () => { export const getNotes = async () => {
baseNotes.value = {} baseNotes.value = {}
let notes: BaseNotes = {} let notes: BaseNotes = {}
if (activeNotesSource.value === 'local') { if (activeNotesSource.value === 'local') {
try { try {
notes = JSON.parse(localStorage.getItem('notes') || '{}') notes = JSON.parse(localStorage.getItem('notes') || '{}')
} catch (error) { } catch (error) {
console.log(error) console.log(error)
}
} else if (activeNotesSource.value === 'firebase') {
if (encryptionKey.value === undefined || !user.value || !db.value) return
const firebaseNotes = (
await getDoc(doc(db.value, 'pages', user.value.uid))
).data() as BaseNotes
notes = encryptionKey.value
? decryptNotes(firebaseNotes, encryptionKey.value)
: firebaseNotes || {}
console.log('get notes from firebase', notes)
} }
} else if (activeNotesSource.value === 'firebase') { baseNotes.value = parseBaseNotes(notes)
if (encryptionKey.value === undefined || !user.value || !db.value) return if (!rootNote.value) insertDefaultNotes(defaultNotes)
const firebaseNotes = (await getDoc(doc(db.value, 'pages', user.value.uid))).data() as BaseNotes setActiveNote(rootNote.value?.id, false)
notes = encryptionKey.value
? decryptNotes(firebaseNotes, encryptionKey.value)
: firebaseNotes || {}
console.log('get notes from firebase', notes)
}
baseNotes.value = parseBaseNotes(notes)
if (!rootNote.value) insertDefaultNotes(defaultNotes)
setActiveNote(rootNote.value?.id, false)
} }

View File

@@ -1,24 +1,24 @@
import type { notesSourceValues } from '@/composables/useNotes' import type { notesSourceValues } from '@/composables/useNotes'
interface Settings { interface Settings {
preferredNotesSource: notesSourceValues | null preferredNotesSource: notesSourceValues | null
} }
export const preferredNotesSource = ref<notesSourceValues | null>(null) export const preferredNotesSource = ref<notesSourceValues | null>(null)
const updateSettings = () => { const updateSettings = () => {
const settings: Settings = { const settings: Settings = {
preferredNotesSource: preferredNotesSource.value preferredNotesSource: preferredNotesSource.value
} }
localStorage.setItem('settings', JSON.stringify(settings)) localStorage.setItem('settings', JSON.stringify(settings))
} }
export const initializeSettings = () => { export const initializeSettings = () => {
watch([preferredNotesSource], () => updateSettings()) watch([preferredNotesSource], () => updateSettings())
try { try {
const settings: Settings = JSON.parse(localStorage.getItem('settings') || '{}') const settings: Settings = JSON.parse(localStorage.getItem('settings') || '{}')
preferredNotesSource.value = settings.preferredNotesSource preferredNotesSource.value = settings.preferredNotesSource
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }

View File

@@ -1,6 +1,6 @@
export const viewModes: ViewMode[] = [ export const viewModes: ViewMode[] = [
{ name: 'Note', icon: 'fas fa-sticky-note fa-fw' }, { name: 'Note', icon: 'fas fa-sticky-note fa-fw' },
{ name: 'List', icon: 'fas fa-list fa-fw' }, { name: 'List', icon: 'fas fa-list fa-fw' },
{ name: 'Mindmap', icon: 'fas fa-project-diagram fa-fw' } { name: 'Mindmap', icon: 'fas fa-project-diagram fa-fw' }
] ]
export const activeViewMode = ref<ViewMode>(viewModes[0]) export const activeViewMode = ref<ViewMode>(viewModes[0])

View File

@@ -9,14 +9,14 @@ import { StatusBar, Style } from '@capacitor/status-bar'
import { Keyboard } from '@capacitor/keyboard' import { Keyboard } from '@capacitor/keyboard'
if (Capacitor.isNativePlatform()) { if (Capacitor.isNativePlatform()) {
StatusBar.setStyle({ style: Style.Dark }) StatusBar.setStyle({ style: Style.Dark })
Keyboard.setAccessoryBarVisible({ isVisible: true }) Keyboard.setAccessoryBarVisible({ isVisible: true })
} }
initializeFirebase() initializeFirebase()
const isDark = usePreferredDark() const isDark = usePreferredDark()
const favicon = computed<string>(() => const favicon = computed<string>(() =>
isDark.value ? '/contexted-white.ico' : '/contexted-black.ico' isDark.value ? '/contexted-white.ico' : '/contexted-black.ico'
) )
useFavicon(favicon) useFavicon(favicon)

View File

@@ -5,92 +5,92 @@
@import '@fontsource/source-sans-pro/300'; @import '@fontsource/source-sans-pro/300';
* { * {
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
html { html {
// height: -webkit-fill-available; // height: -webkit-fill-available;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
body { body {
// min-height: 100vh; // min-height: 100vh;
// min-height: -webkit-fill-available; // min-height: -webkit-fill-available;
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
position: fixed; position: fixed;
transform: translateZ(0); transform: translateZ(0);
touch-action: manipulation; touch-action: manipulation;
-webkit-user-drag: none; -webkit-user-drag: none;
-ms-content-zooming: none; -ms-content-zooming: none;
font-family: 'Source Sans Pro', sans-serif; font-family: 'Source Sans Pro', sans-serif;
// overflow-y: scroll; // overflow-y: scroll;
@apply flex flex-col bg-gray-100; @apply flex flex-col bg-gray-100;
#app { #app {
@apply absolute bottom-0 left-0 right-0 top-0 flex flex-grow flex-col overflow-hidden; @apply absolute bottom-0 left-0 right-0 top-0 flex flex-grow flex-col overflow-hidden;
} }
} }
p:not(:last-child) { p:not(:last-child) {
@apply mb-2; @apply mb-2;
margin-top: 0 !important; margin-top: 0 !important;
} }
p:last-child { p:last-child {
@apply my-0; @apply my-0;
} }
.ck-body-wrapper { .ck-body-wrapper {
height: 0; height: 0;
} }
.ck-content { .ck-content {
padding: 0 !important; padding: 0 !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
line-height: 2rem; line-height: 2rem;
a[data-contexted-link='true'] { a[data-contexted-link='true'] {
@apply cursor-pointer font-semibold text-primary hover:bg-gray-200; @apply cursor-pointer font-semibold text-primary hover:bg-gray-200;
} }
&.ck.ck-editor__editable_inline > * { &.ck.ck-editor__editable_inline > * {
margin: 0 !important; margin: 0 !important;
} }
ol, ol,
ul { ul {
@apply my-2 ps-8; @apply my-2 ps-8;
} }
ol { ol {
@apply list-decimal; @apply list-decimal;
} }
ul { ul {
@apply list-disc; @apply list-disc;
} }
h1, h1,
h2, h2,
h3, h3,
h4 { h4 {
@apply font-semibold; @apply font-semibold;
} }
h1 { h1 {
@apply text-3xl; @apply text-3xl;
} }
h2 { h2 {
@apply text-2xl; @apply text-2xl;
} }
h3 { h3 {
@apply text-xl; @apply text-xl;
} }
h4 { h4 {
@apply text-lg; @apply text-lg;
} }
} }
:root { :root {
--safe-area-top: env(safe-area-inset-top); --safe-area-top: env(safe-area-inset-top);
--safe-area-right: env(safe-area-inset-right); --safe-area-right: env(safe-area-inset-right);
--safe-area-bottom: env(safe-area-inset-bottom); --safe-area-bottom: env(safe-area-inset-bottom);
--safe-area-left: env(safe-area-inset-left); --safe-area-left: env(safe-area-inset-left);
} }

86
src/types.d.ts vendored
View File

@@ -1,48 +1,48 @@
declare global { declare global {
interface BaseNote { interface BaseNote {
id: string id: string
title: string title: string
content: string content: string
created: number created: number
modified: number modified: number
isRoot: boolean isRoot: boolean
}
interface Note extends BaseNote {
wordCount: number
}
interface BaseNotes {
[noteId: string]: BaseNote
}
interface ViewMode {
name: string
icon: string
}
interface AutocompleteEvent {
position?: any
autocompleteText?: string
domElement?: HTMLElement
show: boolean
}
interface NoteRelations {
id: string
to: string[]
from: string[]
}
interface NotesRelations {
[noteId: string]: {
to: string[]
from: string[]
} }
}
type ClientKey = string interface Note extends BaseNote {
type EncryptedEncryptionKey = string wordCount: number
type EncryptionKey = string }
interface BaseNotes {
[noteId: string]: BaseNote
}
interface ViewMode {
name: string
icon: string
}
interface AutocompleteEvent {
position?: any
autocompleteText?: string
domElement?: HTMLElement
show: boolean
}
interface NoteRelations {
id: string
to: string[]
from: string[]
}
interface NotesRelations {
[noteId: string]: {
to: string[]
from: string[]
}
}
type ClientKey = string
type EncryptedEncryptionKey = string
type EncryptionKey = string
} }
export {} export {}

View File

@@ -4,20 +4,20 @@ const encryptionPrefix = 'contexted|'
const salt = 'salt' const salt = 'salt'
export const calculateClientKey = (passphrase: string): ClientKey => { export const calculateClientKey = (passphrase: string): ClientKey => {
return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32 }).toString() return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32 }).toString()
} }
export const decrypt = (encryptedMessage: string, key: string): string => { export const decrypt = (encryptedMessage: string, key: string): string => {
const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8) const decryptedMessage = CryptoJS.AES.decrypt(encryptedMessage, key).toString(CryptoJS.enc.Utf8)
if (!decryptedMessage.startsWith(encryptionPrefix)) if (!decryptedMessage.startsWith(encryptionPrefix))
throw new Error("Message doesn't have valid encryption") throw new Error("Message doesn't have valid encryption")
return decryptedMessage.substring(encryptionPrefix.length) return decryptedMessage.substring(encryptionPrefix.length)
} }
export const encrypt = (unencryptedMessage: string, key: string): string => { export const encrypt = (unencryptedMessage: string, key: string): string => {
return CryptoJS.AES.encrypt(encryptionPrefix + unencryptedMessage, key).toString() return CryptoJS.AES.encrypt(encryptionPrefix + unencryptedMessage, key).toString()
} }
export const generateEncryptionKey = () => { export const generateEncryptionKey = () => {
return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex) return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex)
} }

View File

@@ -1,24 +1,24 @@
import shortid from 'shortid' import shortid from 'shortid'
export const defaultNotes: BaseNote[] = [ export const defaultNotes: BaseNote[] = [
{ {
isRoot: true, isRoot: true,
title: 'Your first note', title: 'Your first note',
content: `Contexted is a **relational note-taking app**. Use it as your personal knowledge base, research assistent or just to map out thoughts.\n\n content: `Contexted is a **relational note-taking app**. Use it as your personal knowledge base, research assistent or just to map out thoughts.\n\n
# How does it work? # How does it work?
* Create a new note by typing words between [[brackets]] * Create a new note by typing words between [[brackets]]
* Click on **Mindmap mode** in the menu left to visualize your notes * Click on **Mindmap mode** in the menu left to visualize your notes
\n \n
Let's get started!` Let's get started!`
}, },
{ {
title: 'brackets', title: 'brackets',
content: `If you type square brackets around text a link is created automatically. Like magic!`, content: `If you type square brackets around text a link is created automatically. Like magic!`,
isRoot: false isRoot: false
} }
].map((note) => ({ ].map((note) => ({
id: shortid.generate(), id: shortid.generate(),
created: new Date().getTime(), created: new Date().getTime(),
modified: new Date().getTime(), modified: new Date().getTime(),
...note ...note
})) }))

View File

@@ -1,20 +1,20 @@
import { formatDistance, format, isToday } from 'date-fns' import { formatDistance, format, isToday } from 'date-fns'
export const formatDate = (date: Date | number): string => { export const formatDate = (date: Date | number): string => {
const dateToFormat = ['number', 'string'].includes(typeof date) ? new Date(date) : date const dateToFormat = ['number', 'string'].includes(typeof date) ? new Date(date) : date
const dateDistanceInWords = formatDistance(dateToFormat, new Date()) + ' ago' const dateDistanceInWords = formatDistance(dateToFormat, new Date()) + ' ago'
return isToday(date) ? dateDistanceInWords : format(date, "d MMMM 'at' HH:mm ") return isToday(date) ? dateDistanceInWords : format(date, "d MMMM 'at' HH:mm ")
} }
export const getAllMatches = (regex: RegExp, input: string): RegExpExecArray[] => { export const getAllMatches = (regex: RegExp, input: string): RegExpExecArray[] => {
const matches = [] const matches = []
let m let m
do { do {
m = regex.exec(input) m = regex.exec(input)
// console.log(m) // console.log(m)
if (m) matches.push(m) if (m) matches.push(m)
} while (m) } while (m)
return matches return matches
} }
export const windowIsMobile = () => window.innerWidth < 640 export const windowIsMobile = () => window.innerWidth < 640

View File

@@ -3,40 +3,40 @@ import DOMPurify from 'dompurify'
import Turndown from 'turndown' import Turndown from 'turndown'
export function mdToHtml(mdText: string): string { export function mdToHtml(mdText: string): string {
const renderer = new marked.Renderer() const renderer = new marked.Renderer()
const html = DOMPurify.sanitize(marked.parse(mdText, { renderer })) const html = DOMPurify.sanitize(marked.parse(mdText, { renderer }))
const re = /(\[\[)(.*?)(\]\])/g const re = /(\[\[)(.*?)(\]\])/g
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('p, b, u, i, li, h1, h2, h3').forEach((element) => { doc.querySelectorAll('p, b, u, i, li, h1, h2, h3').forEach((element) => {
element.innerHTML = element.innerHTML.replace(re, (_, p1, p2, p3) => { element.innerHTML = element.innerHTML.replace(re, (_, p1, p2, p3) => {
return `${p1}<a data-contexted-link="true">${p2}</a>${p3}` return `${p1}<a data-contexted-link="true">${p2}</a>${p3}`
})
}) })
}) return doc.body.innerHTML
return doc.body.innerHTML
} }
export function htmlToMd(htmlText: string): string { export function htmlToMd(htmlText: string): string {
const turndown = new Turndown({ headingStyle: 'atx' }) const turndown = new Turndown({ headingStyle: 'atx' })
const escapes = [ const escapes = [
[/\\/g, '\\\\'], [/\\/g, '\\\\'],
[/\*/g, '\\*'], [/\*/g, '\\*'],
[/^-/g, '\\-'], [/^-/g, '\\-'],
[/^\+ /g, '\\+ '], [/^\+ /g, '\\+ '],
[/^(=+)/g, '\\$1'], [/^(=+)/g, '\\$1'],
[/^(#{1,6}) /g, '\\$1 '], [/^(#{1,6}) /g, '\\$1 '],
[/`/g, '\\`'], [/`/g, '\\`'],
[/^~~~/g, '\\~~~'], [/^~~~/g, '\\~~~'],
// [/\[/g, '\\['], // [/\[/g, '\\['],
// [/\]/g, '\\]'], // [/\]/g, '\\]'],
[/^>/g, '\\>'], [/^>/g, '\\>'],
[/_/g, '\\_'], [/_/g, '\\_'],
[/^(\d+)\. /g, '$1\\. '] [/^(\d+)\. /g, '$1\\. ']
] ]
turndown.escape = (string) => turndown.escape = (string) =>
escapes.reduce((accumulator, escape) => { escapes.reduce((accumulator, escape) => {
return accumulator.replace(escape[0], escape[1] as string) return accumulator.replace(escape[0], escape[1] as string)
}, string) }, string)
const md = turndown.turndown(htmlText) const md = turndown.turndown(htmlText)
return md return md
} }