feat: file explorer
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 50s
This commit is contained in:
@@ -4,6 +4,16 @@
|
||||
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
|
||||
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
|
||||
<button
|
||||
v-if="fileExplorerEnabled"
|
||||
class="btn btn-sm ms-2"
|
||||
:class="showFileExplorer ? 'btn-secondary' : 'btn-outline-secondary'"
|
||||
:title="showFileExplorer ? 'Hide file explorer' : 'Browse & insert files'"
|
||||
@click="showFileExplorer = !showFileExplorer"
|
||||
>
|
||||
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
|
||||
Files
|
||||
</button>
|
||||
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
||||
</div>
|
||||
|
||||
@@ -16,15 +26,19 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<textarea v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
|
||||
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 mt-3 mt-md-0">
|
||||
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
||||
<div class="preview-pane border rounded p-3">
|
||||
<div v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
|
||||
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -73,10 +87,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted } from "vue";
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
||||
import FileExplorer from "./FileExplorer.vue";
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
@@ -91,13 +108,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
spaceId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||
const settingsStore = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const publicSharingEnabled = ref(true);
|
||||
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
||||
|
||||
const editingNote = ref({ ...props.note });
|
||||
const contentTextareaRef = ref(null);
|
||||
const showFileExplorer = ref(false);
|
||||
const fileExplorerPrefix = ref("");
|
||||
const tagsInput = ref(props.note.tags?.join(", ") || "");
|
||||
const passwordAction = ref("keep");
|
||||
const notePassword = ref("");
|
||||
@@ -106,8 +132,18 @@ const saveState = ref("saved");
|
||||
const saveStateTimeout = ref(null);
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = marked.parse(editingNote.value.content || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
|
||||
let clean = DOMPurify.sanitize(html);
|
||||
// Inject access token into space file API URLs so images render without a separate JS fetch
|
||||
const token = authStore.accessToken;
|
||||
if (token && props.spaceId) {
|
||||
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
|
||||
if (url.includes("token=")) return attr + url + quote;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
|
||||
});
|
||||
}
|
||||
return clean;
|
||||
});
|
||||
|
||||
const saveStatusLabel = computed(() => {
|
||||
@@ -197,6 +233,27 @@ const confirmDelete = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Insert markdown snippet at the textarea cursor position. */
|
||||
const insertAtCursor = (snippet) => {
|
||||
const textarea = contentTextareaRef.value;
|
||||
if (!textarea) {
|
||||
editingNote.value.content = (editingNote.value.content || "") + snippet;
|
||||
autoSave();
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? editingNote.value.content?.length ?? 0;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
const before = (editingNote.value.content || "").substring(0, start);
|
||||
const after = (editingNote.value.content || "").substring(end);
|
||||
editingNote.value.content = before + snippet + after;
|
||||
autoSave();
|
||||
nextTick(() => {
|
||||
const newPos = start + snippet.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(saveTimeout.value);
|
||||
clearTimeout(saveStateTimeout.value);
|
||||
|
||||
Reference in New Issue
Block a user