mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
feat: add a markdown preview for description and notes (#1043)
* feat: add a markdown preview for description and notes * feat: add char count for md
This commit is contained in:
@@ -1005,12 +1005,12 @@
|
|||||||
|
|
||||||
.markdown :where(ul) {
|
.markdown :where(ul) {
|
||||||
list-style: disc;
|
list-style: disc;
|
||||||
margin-left: 2rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown :where(ol) {
|
.markdown :where(ol) {
|
||||||
list-style: decimal;
|
list-style: decimal;
|
||||||
margin-left: 2rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
/* Heading Styles */
|
/* Heading Styles */
|
||||||
.markdown :where(h1) {
|
.markdown :where(h1) {
|
||||||
|
|||||||
83
frontend/components/Form/MarkdownEditor.vue
Normal file
83
frontend/components/Form/MarkdownEditor.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import Markdown from "@/components/global/Markdown.vue";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: null,
|
||||||
|
label: null,
|
||||||
|
maxLength: -1,
|
||||||
|
minLength: -1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const local = ref(props.modelValue ?? "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
v => {
|
||||||
|
if (v !== local.value) local.value = v ?? "";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(local, v => emit("update:modelValue", v === "" ? null : v));
|
||||||
|
|
||||||
|
const showPreview = ref(false);
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
const isLengthInvalid = computed(() => {
|
||||||
|
if (typeof local.value !== "string") return false;
|
||||||
|
const len = local.value.length;
|
||||||
|
const max = props.maxLength ?? -1;
|
||||||
|
const min = props.minLength ?? -1;
|
||||||
|
return (max !== -1 && len > max) || (min !== -1 && len < min);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lengthIndicator = computed(() => {
|
||||||
|
if (typeof local.value !== "string") return "";
|
||||||
|
const max = props.maxLength ?? -1;
|
||||||
|
if (max !== -1) {
|
||||||
|
return `${local.value.length}/${max}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="mb-2 grid grid-cols-1 items-center gap-2 md:grid-cols-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<Label :for="id" class="flex min-w-0 items-center gap-2 px-1">
|
||||||
|
<span class="truncate" :title="props.label ?? ''">{{ props.label }}</span>
|
||||||
|
<span class="grow" />
|
||||||
|
<span class="ml-2 text-sm" :class="{ 'text-destructive': isLengthInvalid }">{{ lengthIndicator }}</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1 flex items-center justify-start gap-2 md:col-span-3 md:justify-end">
|
||||||
|
<label class="text-xs text-slate-500">{{ $t("global.preview") }}</label>
|
||||||
|
<Checkbox v-model="showPreview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-4">
|
||||||
|
<Textarea :id="id" v-model="local" autosize class="resize-none" />
|
||||||
|
|
||||||
|
<div v-if="showPreview">
|
||||||
|
<Markdown :source="local" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -25,14 +25,13 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div class="markdown text-wrap break-words" v-html="raw" />
|
<div class="markdown prose text-wrap break-words" v-html="raw" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
* {
|
* {
|
||||||
word-wrap: break-word; /*Fix for long words going out of emelent bounds and issue #407 */
|
word-wrap: break-word; /*Fix for long words going out of emelent bounds and issue #407 */
|
||||||
overflow-wrap: break-word; /*Fix for long words going out of emelent bounds and issue #407 */
|
overflow-wrap: break-word; /*Fix for long words going out of emelent bounds and issue #407 */
|
||||||
white-space: pre-wrap; /*Fix for long words going out of emelent bounds and issue #407 */
|
|
||||||
}
|
}
|
||||||
.markdown {
|
.markdown {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -279,7 +279,8 @@
|
|||||||
"updating": "Updating",
|
"updating": "Updating",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"version": "Version: { version }",
|
"version": "Version: { version }",
|
||||||
"welcome": "Welcome, { username }"
|
"welcome": "Welcome, { username }",
|
||||||
|
"preview": "Preview"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||||
import FormTextField from "~/components/Form/TextField.vue";
|
import FormTextField from "~/components/Form/TextField.vue";
|
||||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||||
|
import MarkdownEditor from "~/components/Form/MarkdownEditor.vue";
|
||||||
import FormDatePicker from "~/components/Form/DatePicker.vue";
|
import FormDatePicker from "~/components/Form/DatePicker.vue";
|
||||||
import FormCheckbox from "~/components/Form/Checkbox.vue";
|
import FormCheckbox from "~/components/Form/Checkbox.vue";
|
||||||
import LocationSelector from "~/components/Location/Selector.vue";
|
import LocationSelector from "~/components/Location/Selector.vue";
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
type DateKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends Date | string ? K : never]: any }>;
|
type DateKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends Date | string ? K : never]: any }>;
|
||||||
|
|
||||||
type TextFormField = {
|
type TextFormField = {
|
||||||
type: "text" | "textarea";
|
type: "text" | "textarea" | "markdown";
|
||||||
label: string;
|
label: string;
|
||||||
ref: NonNullableStringKeys<ItemOut>;
|
ref: NonNullableStringKeys<ItemOut>;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
ref: "quantity",
|
ref: "quantity",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "textarea",
|
type: "markdown",
|
||||||
label: "items.description",
|
label: "items.description",
|
||||||
ref: "description",
|
ref: "description",
|
||||||
maxLength: 1000,
|
maxLength: 1000,
|
||||||
@@ -212,7 +213,7 @@
|
|||||||
maxLength: 255,
|
maxLength: 255,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "textarea",
|
type: "markdown",
|
||||||
label: "items.notes",
|
label: "items.notes",
|
||||||
ref: "notes",
|
ref: "notes",
|
||||||
maxLength: 1000,
|
maxLength: 1000,
|
||||||
@@ -613,6 +614,13 @@
|
|||||||
:max-length="field.maxLength"
|
:max-length="field.maxLength"
|
||||||
:min-length="field.minLength"
|
:min-length="field.minLength"
|
||||||
/>
|
/>
|
||||||
|
<MarkdownEditor
|
||||||
|
v-else-if="field.type === 'markdown'"
|
||||||
|
v-model="item[field.ref]"
|
||||||
|
:label="$t(field.label)"
|
||||||
|
:max-length="field.maxLength"
|
||||||
|
:min-length="field.minLength"
|
||||||
|
/>
|
||||||
<FormTextField
|
<FormTextField
|
||||||
v-else-if="field.type === 'text'"
|
v-else-if="field.type === 'text'"
|
||||||
v-model="item[field.ref]"
|
v-model="item[field.ref]"
|
||||||
|
|||||||
Reference in New Issue
Block a user