WIP: Create new node dialog

Implement folder selector UI element.
Retrieve actual folder structure from Notes library.
This commit is contained in:
Jonatan Heyman 2024-07-25 13:25:19 +02:00
parent 9ee66743d7
commit 5e34656c1d
9 changed files with 542 additions and 60 deletions

View File

@ -67,7 +67,7 @@ export class FileLibrary {
async getList() {
console.log("Loading notes")
const notes = {}
const files = await this.jetpack.findAsync(this.basePath, {
const files = await this.jetpack.findAsync(".", {
matching: "*.txt",
recursive: true,
})
@ -83,6 +83,15 @@ export class FileLibrary {
return notes
}
async getDirectoryList() {
const directories = await this.jetpack.findAsync("", {
files: false,
directories: true,
recursive: true,
})
return directories
}
setupWatcher(win) {
if (!this.watcher) {
this.watcher = fs.watch(
@ -189,6 +198,10 @@ export function setupFileLibraryEventHandlers(library, win) {
return await library.getList()
});
ipcMain.handle('buffer:getDirectoryList', async (event) => {
return await library.getDirectoryList()
});
ipcMain.handle('buffer:exists', async (event, path) => {
return await library.exists(path)
});

View File

@ -65,6 +65,10 @@ contextBridge.exposeInMainWorld("heynote", {
return await ipcRenderer.invoke("buffer:getList")
},
async getDirectoryList() {
return await ipcRenderer.invoke("buffer:getDirectoryList")
},
async load(path) {
return await ipcRenderer.invoke("buffer:load", path)
},

View File

@ -2,7 +2,7 @@
import { mapState, mapActions } from 'pinia'
import { useNotesStore } from "../stores/notes-store"
import FolderSelect from './form/FolderSelect.vue'
import FolderSelector from './folder-selector/FolderSelector.vue'
export default {
data() {
@ -10,21 +10,60 @@
name: "",
filename: "",
tags: [],
directoryTree: null,
parentPath: "",
}
},
components: {
FolderSelect
FolderSelector
},
async mounted() {
await this.updateNotes()
this.$refs.nameInput.focus()
this.updateNotes()
// build directory tree
const directories = await window.heynote.buffer.getDirectoryList()
const rootNode = {
name: "Heynote Root",
path: "",
children: [],
}
const getNodeFromList = (list, part) => list.find(node => node.name === part)
directories.forEach((path) => {
const parts = path.split("/")
let currentLevel = rootNode
let currentParts = []
parts.forEach(part => {
currentParts.push(part)
let node = getNodeFromList(currentLevel.children, part)
if (node) {
currentLevel = node
} else {
node = {
name: part,
children: [],
path: currentParts.join("/"),
}
currentLevel.children.push(node)
currentLevel = node
}
})
})
//console.log("tree:", rootNode)
this.directoryTree = rootNode
},
computed: {
...mapState(useNotesStore, [
"notes",
"currentNotePath",
]),
currentNoteDirectory() {
return this.currentNotePath.split("/").slice(0, -1).join("/")
},
},
methods: {
@ -39,6 +78,15 @@
}
},
onInputKeydown(event) {
// redirect arrow keys and page up/down to folder selector
const redirectKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"]
if (redirectKeys.includes(event.key)) {
this.$refs.folderSelect.$el.dispatchEvent(new KeyboardEvent("keydown", {key: event.key}))
event.preventDefault()
}
},
onSubmit(event) {
event.preventDefault()
console.log("Creating note", this.name)
@ -60,10 +108,18 @@
v-model="name"
class="name-input"
ref="nameInput"
@keydown="onInputKeydown"
/>
<label for="folder-select">Create in</label>
<FolderSelect id="folder-select" />
<FolderSelector
v-if="directoryTree"
:directoryTree="directoryTree"
:selectedPath="currentNoteDirectory"
id="folder-select"
v-model="parentPath"
ref="folderSelect"
/>
</div>
<div class="bottom-bar">
<button type="submit">Create Note</button>
@ -84,12 +140,16 @@
font-size: 13px
//background: #48b57e
background: #efefef
width: 420px
position: absolute
top: 0
left: 50%
transform: translateX(-50%)
border-radius: 0 0 5px 5px
box-shadow: 0 0 10px rgba(0,0,0,0.3)
display: flex
flex-direction: column
max-height: 100%
&:focus
outline: none
+dark-mode
@ -101,6 +161,9 @@
.container
padding: 10px
min-height: 0
display: flex
flex-direction: column
h1
font-weight: bold
@ -115,7 +178,7 @@
.name-input
width: 400px
width: 100%
background: #fff
padding: 4px 5px
border: 1px solid #ccc
@ -138,8 +201,9 @@
.bottom-bar
border-radius: 0 0 5px 5px
background: #e3e3e3
//background: #e3e3e3
padding: 10px
padding-top: 0
display: flex
justify-content: flex-end
+dark-mode
@ -155,3 +219,4 @@
outline-color: #48b57e
</style>
./folder-selector/FolderSelector.vue

View File

@ -0,0 +1,96 @@
<script>
export default {
props: {
name: String,
level: Number,
selected: Boolean,
newFolder: Boolean,
},
watch: {
selected() {
if (this.selected) {
// scrollIntoViewIfNeeded is not supported in all browsers
// See: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded
if (this.$el.scrollIntoViewIfNeeded) {
this.$el.scrollIntoViewIfNeeded({
behavior: "auto",
block: "nearest",
})
} else {
this.$el.scrollIntoView({
behavior: "auto",
block: "nearest",
})
}
}
}
},
computed: {
className() {
return {
folder: true,
selected: this.selected,
new: this.newFolder,
}
},
style() {
return {
"--indent-level": this.level,
}
}
},
}
</script>
<template>
<div
:class="className"
:style="style"
>
<span class="name">{{ name }}</span>
<button class="new-folder" tabindex="-1" @click="$emit('new-folder')">New folder (+)</button>
</div>
</template>
<style lang="sass" scoped>
.folder
padding: 3px 6px
font-size: 13px
padding-left: calc(6px + var(--indent-level) * 16px)
display: flex
scroll-margin-top: 5px
scroll-margin-bottom: 5px
&:hover
background: #f1f1f1
&.selected
background: #48b57e
color: #fff
&:hover
background: #40a773
.new-folder
display: block
color: rgba(255,255,255, 0.9)
&.new
font-style: italic
color: rgba(0,0,0, 0.5)
&.selected
color: rgba(255,255,255, 0.8)
.name
flex-grow: 1
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
.new-folder
background: rgba(0,0,0, 0.15)
border: none
border-radius: 2px
font-size: 10px
display: none
flex-shrink: 0
cursor: pointer
</style>

View File

@ -0,0 +1,231 @@
<script>
import FolderItem from "./FolderItem.vue"
import NewFolderItem from "./NewFolderItem.vue"
export default {
props: {
directoryTree: Object,
selectedPath: String,
},
components: {
FolderItem,
NewFolderItem,
},
data() {
return {
tree: this.directoryTree,
selected: 0,
filter: "",
filterSearchStart: 0,
filterTimeout: null,
}
},
mounted() {
this.selected = this.listItems.findIndex(item => item.path === this.selectedPath)
},
watch: {
directoryTree(newVal) {
this.tree = newVal
},
selected() {
this.$emit("update:modelValue", this.listItems[this.selected].path)
}
},
computed: {
listItems() {
const items = []
const getListItems = (node, level) => {
items.push({
name: node.name,
level: level,
path: node.path,
type: "folder",
createNewFolder: node.createNewFolder,
newFolder: node.newFolder,
})
if (node.createNewFolder) {
items.push({
level: level + 1,
type: "new-folder",
path: node.path,
})
}
if (node.children) {
for (const child of node.children) {
getListItems(child, level + 1)
}
}
}
getListItems(this.tree, 0)
return items
},
},
methods: {
onKeyDown(event) {
//console.log("Keydown", event.key)
if (event.key === "Enter") {
event.preventDefault()
this.$emit("click")
} else if (event.key === "ArrowDown") {
event.preventDefault()
this.selected = Math.min(this.selected + 1, this.listItems.length - 1)
} else if (event.key === "ArrowUp") {
event.preventDefault()
this.selected = Math.max(this.selected - 1, 0)
} else if (event.key === "+") {
event.preventDefault()
this.newFolderDialog(this.listItems[this.selected].path)
} else if (event.key === "-") {
event.preventDefault()
this.removeNewFolder(this.listItems[this.selected].path)
} else if (event.key === "PageDown") {
event.preventDefault()
this.selected = Math.min(this.selected + this.pageCount(), this.listItems.length - 1)
} else if (event.key === "PageUp") {
event.preventDefault()
this.selected = Math.max(this.selected - this.pageCount(), 0)
} else {
if (event.key.length === 1) {
this.filter += event.key
if (this.filter === "") {
this.filterSearchStart = this.selected
}
let idx = this.listItems.findIndex((item, idx) => idx > this.filterSearchStart && item.name.toLowerCase().startsWith(this.filter))
if (idx === -1) {
idx = this.listItems.findIndex((item, idx) => idx < this.filterSearchStart && item.name.toLowerCase().startsWith(this.filter))
}
if (idx !== -1) {
this.selected = idx
this.scheduleFilterReset()
} else {
this.filter = ""
}
}
}
},
scheduleFilterReset() {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout)
this.filterTimeout = null
}
this.filterTimeout = setTimeout(() => {
this.filter = ""
this.filterSearchStart = 0
}, 1000)
},
newFolderDialog(parentPath) {
//console.log("Create new folder in", parentPath)
const node = this.getNode(parentPath)
node.createNewFolder = true
},
createNewFolder(parentPath, name) {
//console.log("Create new folder", name, "in", parentPath)
const node = this.getNode(parentPath)
node.createNewFolder = false
node.children.unshift({
name: name,
path: parentPath === "" ? name : parentPath + "/" + name,
children: [],
newFolder: true,
})
this.selected++
this.$refs.container.focus()
},
cancelNewFolder(path) {
//console.log("Cancel new folder in", path)
const node = this.getNode(path)
node.createNewFolder = false
this.$refs.container.focus()
},
removeNewFolder(path) {
//console.log("Remove newly created folder:", path)
const node = this.getNode(path)
if (node.newFolder && path) {
const parentPath = path.split("/").slice(0, -1).join("/")
const parent = this.getNode(parentPath)
parent.children = parent.children.filter(child => child.path !== path)
this.selected--
}
this.$refs.container.focus()
},
getNode(path) {
const getNodeFromList = (list, part) => list.find(node => node.name === part)
const parts = path.split("/")
let currentLevel = this.tree
for (const part of parts) {
const node = getNodeFromList(currentLevel.children, part)
if (!node) {
return currentLevel
}
currentLevel = node
}
return currentLevel
},
pageCount() {
return Math.max(1, Math.floor(this.$refs.container.clientHeight / 24) - 1)
},
},
}
</script>
<template>
<button
class="folder-select-container"
ref="container"
@keydown="onKeyDown"
@click.stop.prevent="()=>{}"
>
<!--<div class="folder root selected">
Heynote Root
</div>
<div class="folder indent">New Folder&hellip;</div>-->
<template v-for="(item, idx) in listItems">
<FolderItem
v-if="item.type === 'folder'"
:name="item.name"
:level="item.level"
:selected="idx === selected && !item.createNewFolder"
:newFolder="item.newFolder"
@click="selected = idx"
@new-folder="newFolderDialog(item.path)"
/>
<NewFolderItem
v-else-if="item.type === 'new-folder'"
:parentPath="item.path"
:level="item.level"
@cancel="() => cancelNewFolder(item.path)"
@create-folder="createNewFolder"
/>
</template>
</button>
</template>
<style lang="sass" scoped>
.folder-select-container
width: 100%
overflow-y: auto
background: #fff
border: 1px solid #ccc
border-radius: 2px
padding: 5px 5px
text-align: left
&:focus, &:focus-within
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
</style>

View File

@ -0,0 +1,108 @@
<script>
import sanitizeFilename from "./sanitize-filename.js"
export default {
props: {
parentPath: String,
level: Number,
},
data() {
return {
name: "",
eventTriggered: false,
}
},
mounted() {
this.$refs.input.focus()
},
computed: {
className() {
return {
folder: true,
selected: true
}
},
style() {
return {
"--indent-level": this.level,
}
}
},
methods: {
onKeyDown(event) {
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
this.finish()
} else if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
this.name = ""
this.finish()
}
},
finish() {
if (this.eventTriggered) {
return
}
this.eventTriggered = true
if (this.name === "") {
this.$emit("cancel")
} else {
this.$emit("create-folder", this.parentPath, sanitizeFilename(this.name, "_"))
}
},
},
}
</script>
<template>
<div
:class="className"
:style="style"
>
<input
type="text"
v-model="name"
ref="input"
placeholder="New folder name"
maxlength="60"
@keydown.stop="onKeyDown"
@blur="finish"
/>
</div>
</template>
<style lang="sass" scoped>
.folder
padding: 3px 6px
font-size: 13px
padding-left: calc(0px + var(--indent-level) * 16px)
display: flex
background: #f1f1f1
&:hover
background: #f1f1f1
input
width: 100%
background: #fff
border: none
border-radius: 2px
font-size: 13px
height: 16px
padding: 2px 4px
font-style: italic
border: 2px solid #48b57e
&:focus
outline: none
&::placeholder
font-size: 12px
</style>

View File

@ -0,0 +1,14 @@
const illegalRe = /[\/\?<>\\:\*\|"]/g;
const controlRe = /[\x00-\x1f\x80-\x9f]/g;
const reservedRe = /^\.+$/;
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
const windowsTrailingRe = /[\. ]+$/;
export default function sanitizeFilename(input, replacement) {
return input.trim()
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
.replace(reservedRe, replacement)
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement)
}

View File

@ -1,53 +0,0 @@
<script>
export default {
props: {
tree: Array,
},
methods: {
onKeyDown(event) {
console.log("Keydown", event.key)
if (event.key === "Enter") {
event.preventDefault()
this.$emit("click")
}
},
}
}
</script>
<template>
<button
class="folder-select-container"
@keydown="onKeyDown"
@click.stop.prevent="()=>{}"
>
<div class="folder root selected">
Heynote Root
</div>
<div class="folder indent">New Folder&hellip;</div>
</button>
</template>
<style lang="sass" scoped>
.folder-select-container
width: 100%
background: #fff
border: 1px solid #ccc
border-radius: 2px
padding: 5px 5px
text-align: left
&:focus
outline: none
border: 1px solid #fff
outline: 2px solid #48b57e
.folder
padding: 3px 6px
font-size: 13px
&.selected
background: #48b57e
color: #fff
&.indent
padding-left: 16px
</style>

View File

@ -103,6 +103,10 @@ const Heynote = {
return [{"path":"buffer.txt", "metadata":{}}]
},
async getDirectoryList() {
return []
},
async close(path) {
},