@xelqun/forms 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -61
- package/package.json +32 -2
- package/resources/css/xelqun.css +121 -0
- package/resources/js/components/XqAction.vue +143 -0
- package/resources/js/components/XqFieldSlot.vue +20 -0
- package/resources/js/components/XqFieldWrapper.vue +74 -30
- package/resources/js/components/XqForm.vue +36 -9
- package/resources/js/components/XqNode.vue +8 -1
- package/resources/js/components/XqUnknownNode.vue +2 -1
- package/resources/js/components/defaults.ts +36 -0
- package/resources/js/components/fields/XqBlockPicker.vue +68 -0
- package/resources/js/components/fields/XqCheckboxField.vue +35 -9
- package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
- package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
- package/resources/js/components/fields/XqColorPickerField.vue +156 -0
- package/resources/js/components/fields/XqDatePickerField.vue +149 -53
- package/resources/js/components/fields/XqFileUploadField.vue +524 -165
- package/resources/js/components/fields/XqImageEditor.vue +165 -0
- package/resources/js/components/fields/XqKeyValueField.vue +168 -0
- package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
- package/resources/js/components/fields/XqRadioField.vue +9 -5
- package/resources/js/components/fields/XqRepeaterField.vue +314 -79
- package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
- package/resources/js/components/fields/XqRichEditorField.vue +280 -0
- package/resources/js/components/fields/XqSelectField.vue +29 -12
- package/resources/js/components/fields/XqSliderField.vue +47 -0
- package/resources/js/components/fields/XqTagsInputField.vue +127 -0
- package/resources/js/components/fields/XqTextInputField.vue +121 -12
- package/resources/js/components/fields/XqTextareaField.vue +39 -16
- package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
- package/resources/js/components/fields/XqToggleField.vue +33 -9
- package/resources/js/components/layouts/XqActions.vue +19 -0
- package/resources/js/components/layouts/XqBlock.vue +52 -0
- package/resources/js/components/layouts/XqCallout.vue +46 -0
- package/resources/js/components/layouts/XqEmptyState.vue +37 -0
- package/resources/js/components/layouts/XqFlex.vue +23 -0
- package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
- package/resources/js/components/layouts/XqIconNode.vue +32 -0
- package/resources/js/components/layouts/XqImageNode.vue +30 -0
- package/resources/js/components/layouts/XqSection.vue +3 -1
- package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
- package/resources/js/components/layouts/XqTextNode.vue +49 -0
- package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
- package/resources/js/components/layouts/XqWizard.vue +168 -0
- package/resources/js/composables/context.ts +14 -0
- package/resources/js/composables/useField.ts +21 -3
- package/resources/js/composables/usePanelErrors.ts +37 -0
- package/resources/js/composables/useSortable.ts +70 -0
- package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
- package/resources/js/core/dates.ts +38 -0
- package/resources/js/core/images.ts +110 -0
- package/resources/js/core/log.ts +1 -1
- package/resources/js/core/markdown.ts +61 -0
- package/resources/js/core/mask.ts +42 -0
- package/resources/js/core/patch.ts +6 -3
- package/resources/js/core/paths.ts +11 -14
- package/resources/js/core/store.ts +515 -25
- package/resources/js/core/translate.ts +17 -0
- package/resources/js/core/validation.ts +126 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/lib/utils.ts +15 -0
- package/resources/js/plugin.ts +103 -6
- package/resources/js/schema/types.ts +102 -0
- package/resources/js/testing.ts +50 -0
- package/resources/js/ui/XqCalendar.vue +4 -0
- package/resources/js/ui/XqCombobox.vue +89 -32
- package/resources/js/ui/XqConfirmDialog.vue +58 -0
- package/resources/js/ui/XqDialog.vue +46 -0
- package/resources/js/ui/XqIcon.vue +28 -0
- package/resources/js/ui/XqSelect.vue +38 -18
- package/resources/js/ui/XqTabs.vue +1 -1
- package/resources/js/ui/XqTimeField.vue +66 -0
- package/resources/js/ui/classes.ts +33 -0
- package/resources/js/ui/icons.ts +95 -0
- package/resources/js/ui/index.ts +5 -0
- package/resources/js/ui/options.ts +46 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Cropper from 'cropperjs';
|
|
3
|
+
import 'cropperjs/dist/cropper.css';
|
|
4
|
+
import { FlipHorizontal, FlipVertical, RotateCcw, RotateCw, Undo2 } from 'lucide-vue-next';
|
|
5
|
+
import { computed, onBeforeUnmount, ref } from 'vue';
|
|
6
|
+
import { useTranslator } from '../../composables/context';
|
|
7
|
+
import { toolbarButtonClass } from '../../ui/classes';
|
|
8
|
+
import XqButton from '../../ui/XqButton.vue';
|
|
9
|
+
import XqDialog from '../../ui/XqDialog.vue';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
src: string;
|
|
13
|
+
name: string;
|
|
14
|
+
aspectRatios?: Array<string | null> | null;
|
|
15
|
+
fillColor?: string | null;
|
|
16
|
+
}>();
|
|
17
|
+
const emit = defineEmits<{ save: [file: File]; cancel: [] }>();
|
|
18
|
+
const t = useTranslator();
|
|
19
|
+
const open = ref(true);
|
|
20
|
+
const failed = ref(false);
|
|
21
|
+
const image = ref<HTMLImageElement | null>(null);
|
|
22
|
+
let cropper: Cropper | null = null;
|
|
23
|
+
let scaleX = 1;
|
|
24
|
+
let scaleY = 1;
|
|
25
|
+
|
|
26
|
+
const ratios = computed(() => props.aspectRatios ?? []);
|
|
27
|
+
|
|
28
|
+
function toNumber(ratio: string | null): number {
|
|
29
|
+
const [width, height] = (ratio ?? '').split(':').map(Number);
|
|
30
|
+
|
|
31
|
+
return width > 0 && height > 0 ? width / height : NaN;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function start(): void {
|
|
35
|
+
cropper = new Cropper(image.value!, {
|
|
36
|
+
viewMode: 1,
|
|
37
|
+
autoCropArea: 1,
|
|
38
|
+
aspectRatio: ratios.value.length ? toNumber(ratios.value[0]) : NaN,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flip(axis: 'x' | 'y'): void {
|
|
43
|
+
if (axis === 'x') {
|
|
44
|
+
scaleX = -scaleX;
|
|
45
|
+
cropper?.scaleX(scaleX);
|
|
46
|
+
} else {
|
|
47
|
+
scaleY = -scaleY;
|
|
48
|
+
cropper?.scaleY(scaleY);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function close(): void {
|
|
53
|
+
open.value = false;
|
|
54
|
+
emit('cancel');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function save(): void {
|
|
58
|
+
// A stored file on another origin taints the canvas, unless that origin allows it.
|
|
59
|
+
try {
|
|
60
|
+
cropper
|
|
61
|
+
?.getCroppedCanvas({ fillColor: props.fillColor ?? 'transparent' })
|
|
62
|
+
.toBlob((blob) => {
|
|
63
|
+
if (blob) {
|
|
64
|
+
emit('save', new File([blob], props.name, { type: blob.type }));
|
|
65
|
+
} else {
|
|
66
|
+
failed.value = true;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
failed.value = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onBeforeUnmount(() => cropper?.destroy());
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<XqDialog
|
|
79
|
+
:open="open"
|
|
80
|
+
:title="t('Edit image')"
|
|
81
|
+
:description="name"
|
|
82
|
+
width="2xl"
|
|
83
|
+
@update:open="$event || close()"
|
|
84
|
+
>
|
|
85
|
+
<div class="gap-3 grid">
|
|
86
|
+
<div class="max-h-[60vh] overflow-hidden">
|
|
87
|
+
<img
|
|
88
|
+
ref="image"
|
|
89
|
+
:src="src"
|
|
90
|
+
:alt="name"
|
|
91
|
+
crossorigin="anonymous"
|
|
92
|
+
class="block max-w-full"
|
|
93
|
+
@load="start"
|
|
94
|
+
@error="failed = true"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
<p v-if="failed" role="alert" class="text-xs font-medium text-xq-destructive">
|
|
98
|
+
{{ t('The image could not be edited.') }}
|
|
99
|
+
</p>
|
|
100
|
+
<div class="gap-1 flex flex-wrap items-center">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
:class="toolbarButtonClass"
|
|
104
|
+
:aria-label="t('Rotate left')"
|
|
105
|
+
@click="cropper?.rotate(-90)"
|
|
106
|
+
>
|
|
107
|
+
<RotateCcw class="size-4" aria-hidden="true" />
|
|
108
|
+
</button>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
:class="toolbarButtonClass"
|
|
112
|
+
:aria-label="t('Rotate right')"
|
|
113
|
+
@click="cropper?.rotate(90)"
|
|
114
|
+
>
|
|
115
|
+
<RotateCw class="size-4" aria-hidden="true" />
|
|
116
|
+
</button>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
:class="toolbarButtonClass"
|
|
120
|
+
:aria-label="t('Flip horizontally')"
|
|
121
|
+
@click="flip('x')"
|
|
122
|
+
>
|
|
123
|
+
<FlipHorizontal class="size-4" aria-hidden="true" />
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
:class="toolbarButtonClass"
|
|
128
|
+
:aria-label="t('Flip vertically')"
|
|
129
|
+
@click="flip('y')"
|
|
130
|
+
>
|
|
131
|
+
<FlipVertical class="size-4" aria-hidden="true" />
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
:class="toolbarButtonClass"
|
|
136
|
+
:aria-label="t('Reset')"
|
|
137
|
+
@click="cropper?.reset()"
|
|
138
|
+
>
|
|
139
|
+
<Undo2 class="size-4" aria-hidden="true" />
|
|
140
|
+
</button>
|
|
141
|
+
<div v-if="ratios.length" class="gap-1 ms-auto flex flex-wrap">
|
|
142
|
+
<XqButton
|
|
143
|
+
v-for="ratio in ratios"
|
|
144
|
+
:key="ratio ?? 'free'"
|
|
145
|
+
type="button"
|
|
146
|
+
variant="outline"
|
|
147
|
+
size="sm"
|
|
148
|
+
:data-xq-ratio="ratio ?? 'free'"
|
|
149
|
+
@click="cropper?.setAspectRatio(toNumber(ratio))"
|
|
150
|
+
>
|
|
151
|
+
{{ ratio ?? t('Free') }}
|
|
152
|
+
</XqButton>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="gap-2 flex justify-end">
|
|
156
|
+
<XqButton type="button" variant="outline" size="sm" @click="close">
|
|
157
|
+
{{ t('Cancel') }}
|
|
158
|
+
</XqButton>
|
|
159
|
+
<XqButton type="button" size="sm" data-xq-image-save @click="save">
|
|
160
|
+
{{ t('Save') }}
|
|
161
|
+
</XqButton>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</XqDialog>
|
|
165
|
+
</template>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ChevronDown, ChevronUp, GripVertical, Plus, X } from 'lucide-vue-next';
|
|
3
|
+
import { computed, ref, watch } from 'vue';
|
|
4
|
+
import { useField } from '../../composables/useField';
|
|
5
|
+
import { useSortable } from '../../composables/useSortable';
|
|
6
|
+
import type { SchemaNode } from '../../schema/types';
|
|
7
|
+
import { moveItem } from '../../ui/options';
|
|
8
|
+
import XqButton from '../../ui/XqButton.vue';
|
|
9
|
+
import XqInput from '../../ui/XqInput.vue';
|
|
10
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
11
|
+
import { useTranslator } from '../../composables/context';
|
|
12
|
+
|
|
13
|
+
const t = useTranslator();
|
|
14
|
+
interface Row {
|
|
15
|
+
id: number;
|
|
16
|
+
key: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
21
|
+
const { value, error, describedBy, required, disabled, id } = useField<Record<string, unknown>>(
|
|
22
|
+
props.node,
|
|
23
|
+
);
|
|
24
|
+
const options = computed(() => props.node.props ?? {});
|
|
25
|
+
let nextRowId = 0;
|
|
26
|
+
|
|
27
|
+
function toRows(state: unknown): Row[] {
|
|
28
|
+
const map = state && !Array.isArray(state) ? (state as Record<string, unknown>) : {};
|
|
29
|
+
|
|
30
|
+
return Object.entries(map).map(([key, item]) => ({
|
|
31
|
+
id: nextRowId++,
|
|
32
|
+
key,
|
|
33
|
+
value: item === null || item === undefined ? '' : String(item),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toMap(list: Row[]): Record<string, string> {
|
|
38
|
+
return Object.fromEntries(
|
|
39
|
+
list.filter((row) => row.key.trim() !== '').map((row) => [row.key, row.value]),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Rows keep a blank key and a stable id while typing, which the map can't hold. */
|
|
44
|
+
const rows = ref<Row[]>(toRows(value.value));
|
|
45
|
+
|
|
46
|
+
function commit(): void {
|
|
47
|
+
value.value = toMap(rows.value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
watch(value, (next) => {
|
|
51
|
+
if (JSON.stringify(next ?? {}) !== JSON.stringify(toMap(rows.value))) {
|
|
52
|
+
rows.value = toRows(next);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function add(): void {
|
|
57
|
+
rows.value.push({ id: nextRowId++, key: '', value: '' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function remove(row: Row): void {
|
|
61
|
+
rows.value = rows.value.filter((item) => item.id !== row.id);
|
|
62
|
+
commit();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function move(row: Row, offset: number): void {
|
|
66
|
+
rows.value = moveItem(rows.value, row, offset);
|
|
67
|
+
commit();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rowList = ref<HTMLElement | null>(null);
|
|
71
|
+
|
|
72
|
+
useSortable(rowList, {
|
|
73
|
+
draggable: '> [data-xq-row]',
|
|
74
|
+
handle: '[data-xq-drag]',
|
|
75
|
+
enabled: () => !!options.value.reorderable && !disabled.value && rows.value.length > 1,
|
|
76
|
+
onMove: (from, to) => move(rows.value[from], to - from),
|
|
77
|
+
});
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<XqFieldWrapper :node="node" :id="id" :label-for="null" :error="error" :required="required">
|
|
82
|
+
<div class="gap-2 grid">
|
|
83
|
+
<div
|
|
84
|
+
v-if="rows.length"
|
|
85
|
+
class="gap-2 text-xs font-medium grid grid-cols-2 text-xq-muted-foreground"
|
|
86
|
+
>
|
|
87
|
+
<span>{{ options.keyLabel }}</span>
|
|
88
|
+
<span>{{ options.valueLabel }}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div ref="rowList" class="gap-2 grid">
|
|
91
|
+
<div
|
|
92
|
+
v-for="(row, index) in rows"
|
|
93
|
+
:key="row.id"
|
|
94
|
+
data-xq-row
|
|
95
|
+
class="gap-2 flex items-center"
|
|
96
|
+
>
|
|
97
|
+
<span
|
|
98
|
+
v-if="options.reorderable && rows.length > 1"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
data-xq-drag
|
|
101
|
+
class="cursor-grab touch-none text-xq-muted-foreground"
|
|
102
|
+
>
|
|
103
|
+
<GripVertical class="size-4" />
|
|
104
|
+
</span>
|
|
105
|
+
<XqInput
|
|
106
|
+
v-model="row.key"
|
|
107
|
+
:aria-label="`${options.keyLabel} ${index + 1}`"
|
|
108
|
+
:aria-describedby="describedBy"
|
|
109
|
+
:placeholder="options.keyPlaceholder ?? undefined"
|
|
110
|
+
:disabled="disabled || !options.editableKeys"
|
|
111
|
+
@update:model-value="commit"
|
|
112
|
+
/>
|
|
113
|
+
<XqInput
|
|
114
|
+
v-model="row.value"
|
|
115
|
+
:aria-label="`${options.valueLabel} ${index + 1}`"
|
|
116
|
+
:placeholder="options.valuePlaceholder ?? undefined"
|
|
117
|
+
:disabled="disabled || !options.editableValues"
|
|
118
|
+
@update:model-value="commit"
|
|
119
|
+
/>
|
|
120
|
+
<template v-if="options.reorderable">
|
|
121
|
+
<XqButton
|
|
122
|
+
type="button"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="icon-sm"
|
|
125
|
+
:aria-label="t('Move row :number up', { number: index + 1 })"
|
|
126
|
+
:disabled="disabled || index === 0"
|
|
127
|
+
@click="move(row, -1)"
|
|
128
|
+
>
|
|
129
|
+
<ChevronUp />
|
|
130
|
+
</XqButton>
|
|
131
|
+
<XqButton
|
|
132
|
+
type="button"
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="icon-sm"
|
|
135
|
+
:aria-label="t('Move row :number down', { number: index + 1 })"
|
|
136
|
+
:disabled="disabled || index === rows.length - 1"
|
|
137
|
+
@click="move(row, 1)"
|
|
138
|
+
>
|
|
139
|
+
<ChevronDown />
|
|
140
|
+
</XqButton>
|
|
141
|
+
</template>
|
|
142
|
+
<XqButton
|
|
143
|
+
v-if="options.deletable"
|
|
144
|
+
type="button"
|
|
145
|
+
variant="ghost"
|
|
146
|
+
size="icon-sm"
|
|
147
|
+
:aria-label="t('Remove row :number', { number: index + 1 })"
|
|
148
|
+
:disabled="disabled"
|
|
149
|
+
@click="remove(row)"
|
|
150
|
+
>
|
|
151
|
+
<X />
|
|
152
|
+
</XqButton>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<XqButton
|
|
156
|
+
v-if="options.addable"
|
|
157
|
+
type="button"
|
|
158
|
+
variant="outline"
|
|
159
|
+
size="sm"
|
|
160
|
+
class="justify-self-start"
|
|
161
|
+
:disabled="disabled"
|
|
162
|
+
@click="add"
|
|
163
|
+
>
|
|
164
|
+
<Plus />{{ options.addLabel }}
|
|
165
|
+
</XqButton>
|
|
166
|
+
</div>
|
|
167
|
+
</XqFieldWrapper>
|
|
168
|
+
</template>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Bold,
|
|
4
|
+
Code,
|
|
5
|
+
Heading,
|
|
6
|
+
ImagePlus,
|
|
7
|
+
Italic,
|
|
8
|
+
Link as LinkIcon,
|
|
9
|
+
List,
|
|
10
|
+
ListOrdered,
|
|
11
|
+
LoaderCircle,
|
|
12
|
+
Quote,
|
|
13
|
+
Strikethrough,
|
|
14
|
+
Table,
|
|
15
|
+
} from 'lucide-vue-next';
|
|
16
|
+
import type { Component } from 'vue';
|
|
17
|
+
import { computed, nextTick, ref } from 'vue';
|
|
18
|
+
import { useField } from '../../composables/useField';
|
|
19
|
+
import { insertBlock, prefixLines, wrapSelection } from '../../core/markdown';
|
|
20
|
+
import type { TextEdit } from '../../core/markdown';
|
|
21
|
+
import { cn } from '../../lib/utils';
|
|
22
|
+
import type { SchemaNode } from '../../schema/types';
|
|
23
|
+
import { controlClass, spinnerClass, toolbarButtonClass } from '../../ui/classes';
|
|
24
|
+
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{ node: SchemaNode }>();
|
|
27
|
+
const { store, path, value, error, describedBy, required, disabled, readOnly, id, onBlur } =
|
|
28
|
+
useField<string | null>(props.node);
|
|
29
|
+
const { t } = store;
|
|
30
|
+
const options = computed(() => props.node.props ?? {});
|
|
31
|
+
const textarea = ref<HTMLTextAreaElement | null>(null);
|
|
32
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
33
|
+
const previewing = ref(false);
|
|
34
|
+
const preview = ref<string | null>(null);
|
|
35
|
+
const loadingPreview = ref(false);
|
|
36
|
+
const previewAction = computed(() =>
|
|
37
|
+
props.node.children?.find((child) => child.slot === 'markdownPreview'),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
type Edit = (text: string, start: number, end: number) => TextEdit;
|
|
41
|
+
|
|
42
|
+
const table = '| Column | Column |\n| ------ | ------ |\n| Cell | Cell |';
|
|
43
|
+
|
|
44
|
+
const buttons: Record<string, { label: () => string; icon: Component; edit?: Edit }> = {
|
|
45
|
+
bold: {
|
|
46
|
+
label: () => t('Bold'),
|
|
47
|
+
icon: Bold,
|
|
48
|
+
edit: (text, start, end) => wrapSelection(text, start, end, '**', '**', t('Text')),
|
|
49
|
+
},
|
|
50
|
+
italic: {
|
|
51
|
+
label: () => t('Italic'),
|
|
52
|
+
icon: Italic,
|
|
53
|
+
edit: (text, start, end) => wrapSelection(text, start, end, '*', '*', t('Text')),
|
|
54
|
+
},
|
|
55
|
+
strike: {
|
|
56
|
+
label: () => t('Strikethrough'),
|
|
57
|
+
icon: Strikethrough,
|
|
58
|
+
edit: (text, start, end) => wrapSelection(text, start, end, '~~', '~~', t('Text')),
|
|
59
|
+
},
|
|
60
|
+
link: {
|
|
61
|
+
label: () => t('Link'),
|
|
62
|
+
icon: LinkIcon,
|
|
63
|
+
edit: (text, start, end) => wrapSelection(text, start, end, '[', '](https://)', t('Text')),
|
|
64
|
+
},
|
|
65
|
+
heading: {
|
|
66
|
+
label: () => t('Heading'),
|
|
67
|
+
icon: Heading,
|
|
68
|
+
edit: (text, start, end) => prefixLines(text, start, end, '## '),
|
|
69
|
+
},
|
|
70
|
+
blockquote: {
|
|
71
|
+
label: () => t('Quote'),
|
|
72
|
+
icon: Quote,
|
|
73
|
+
edit: (text, start, end) => prefixLines(text, start, end, '> '),
|
|
74
|
+
},
|
|
75
|
+
codeBlock: {
|
|
76
|
+
label: () => t('Code block'),
|
|
77
|
+
icon: Code,
|
|
78
|
+
edit: (text, start, end) => wrapSelection(text, start, end, '```\n', '\n```'),
|
|
79
|
+
},
|
|
80
|
+
bulletList: {
|
|
81
|
+
label: () => t('Bulleted list'),
|
|
82
|
+
icon: List,
|
|
83
|
+
edit: (text, start, end) => prefixLines(text, start, end, '- '),
|
|
84
|
+
},
|
|
85
|
+
orderedList: {
|
|
86
|
+
label: () => t('Numbered list'),
|
|
87
|
+
icon: ListOrdered,
|
|
88
|
+
edit: (text, start, end) => prefixLines(text, start, end, '#'),
|
|
89
|
+
},
|
|
90
|
+
table: {
|
|
91
|
+
label: () => t('Table'),
|
|
92
|
+
icon: Table,
|
|
93
|
+
edit: (text, _, end) => insertBlock(text, end, table),
|
|
94
|
+
},
|
|
95
|
+
attachFiles: { label: () => t('Attach a file'), icon: ImagePlus },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function apply(edit: Edit): Promise<void> {
|
|
99
|
+
const element = textarea.value;
|
|
100
|
+
|
|
101
|
+
if (!element) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = edit(value.value ?? '', element.selectionStart, element.selectionEnd);
|
|
106
|
+
|
|
107
|
+
value.value = result.text;
|
|
108
|
+
await nextTick();
|
|
109
|
+
element.focus();
|
|
110
|
+
element.setSelectionRange(result.selectionStart, result.selectionEnd);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function press(name: string): void {
|
|
114
|
+
const edit = buttons[name]?.edit;
|
|
115
|
+
|
|
116
|
+
if (edit) {
|
|
117
|
+
void apply(edit);
|
|
118
|
+
} else if (name === 'attachFiles') {
|
|
119
|
+
fileInput.value?.click();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function attach(event: Event): Promise<void> {
|
|
124
|
+
const input = event.target as HTMLInputElement;
|
|
125
|
+
const file = input.files?.[0];
|
|
126
|
+
|
|
127
|
+
input.value = '';
|
|
128
|
+
|
|
129
|
+
if (file) {
|
|
130
|
+
const { token } = await store.upload(path, file, () => {});
|
|
131
|
+
|
|
132
|
+
await apply((text, _, end) => insertBlock(text, end, ``));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function showPreview(): Promise<void> {
|
|
137
|
+
previewing.value = true;
|
|
138
|
+
|
|
139
|
+
if (previewAction.value) {
|
|
140
|
+
loadingPreview.value = true;
|
|
141
|
+
|
|
142
|
+
const response = await store.runAction(previewAction.value);
|
|
143
|
+
|
|
144
|
+
preview.value = (response?.result as string | undefined) ?? null;
|
|
145
|
+
loadingPreview.value = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<XqFieldWrapper :node="node" :id="id" :error="error" :required="required">
|
|
152
|
+
<div
|
|
153
|
+
:class="
|
|
154
|
+
cn(
|
|
155
|
+
controlClass,
|
|
156
|
+
'focus-within:border-xq-ring focus-within:ring-[3px] focus-within:ring-xq-ring/25',
|
|
157
|
+
)
|
|
158
|
+
"
|
|
159
|
+
>
|
|
160
|
+
<div class="gap-1 p-1 flex flex-wrap items-center border-b border-xq-border">
|
|
161
|
+
<template v-if="!disabled">
|
|
162
|
+
<div
|
|
163
|
+
v-for="(group, index) in options.toolbar ?? []"
|
|
164
|
+
:key="index"
|
|
165
|
+
class="gap-0.5 flex"
|
|
166
|
+
>
|
|
167
|
+
<template v-for="name in group" :key="name">
|
|
168
|
+
<button
|
|
169
|
+
v-if="buttons[name]"
|
|
170
|
+
type="button"
|
|
171
|
+
:class="toolbarButtonClass"
|
|
172
|
+
:aria-label="buttons[name].label()"
|
|
173
|
+
:title="buttons[name].label()"
|
|
174
|
+
:disabled="previewing"
|
|
175
|
+
:data-xq-tool="name"
|
|
176
|
+
@click="press(name)"
|
|
177
|
+
>
|
|
178
|
+
<component
|
|
179
|
+
:is="buttons[name].icon"
|
|
180
|
+
class="size-4"
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
/>
|
|
183
|
+
</button>
|
|
184
|
+
</template>
|
|
185
|
+
</div>
|
|
186
|
+
</template>
|
|
187
|
+
<div v-if="previewAction && !disabled" class="gap-1 ms-auto flex">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
class="px-2 py-1 text-xs cursor-pointer rounded-xq-sm hover:bg-xq-accent aria-pressed:bg-xq-accent"
|
|
191
|
+
:aria-pressed="!previewing"
|
|
192
|
+
@click="previewing = false"
|
|
193
|
+
>
|
|
194
|
+
{{ t('Write') }}
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
class="px-2 py-1 text-xs cursor-pointer rounded-xq-sm hover:bg-xq-accent aria-pressed:bg-xq-accent"
|
|
199
|
+
:aria-pressed="previewing"
|
|
200
|
+
data-xq-markdown-preview
|
|
201
|
+
@click="showPreview"
|
|
202
|
+
>
|
|
203
|
+
{{ t('Preview') }}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<textarea
|
|
208
|
+
v-show="!previewing"
|
|
209
|
+
:id="id"
|
|
210
|
+
ref="textarea"
|
|
211
|
+
:value="value ?? ''"
|
|
212
|
+
:readonly="readOnly"
|
|
213
|
+
:disabled="disabled && !readOnly"
|
|
214
|
+
:aria-invalid="error ? true : undefined"
|
|
215
|
+
:aria-describedby="describedBy"
|
|
216
|
+
:placeholder="store.text(node, 'placeholder') ?? undefined"
|
|
217
|
+
:style="{ minHeight: options.minHeight ?? '10rem', maxHeight: options.maxHeight }"
|
|
218
|
+
class="px-3 py-2 text-sm font-mono block w-full resize-y bg-transparent outline-none"
|
|
219
|
+
@input="value = ($event.target as HTMLTextAreaElement).value"
|
|
220
|
+
@blur="onBlur"
|
|
221
|
+
/>
|
|
222
|
+
<div
|
|
223
|
+
v-if="previewing"
|
|
224
|
+
class="px-3 py-2 text-sm"
|
|
225
|
+
:style="{ minHeight: options.minHeight ?? '10rem' }"
|
|
226
|
+
>
|
|
227
|
+
<LoaderCircle v-if="loadingPreview" :class="spinnerClass" aria-hidden="true" />
|
|
228
|
+
<!-- MarkdownEditor::render() strips raw HTML and cleans the result on the server. -->
|
|
229
|
+
<div v-else-if="preview !== null" class="xq-rich-content" v-html="preview" />
|
|
230
|
+
<p v-else class="text-xq-muted-foreground">
|
|
231
|
+
{{ t('Could not load the preview.') }}
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
<input
|
|
235
|
+
ref="fileInput"
|
|
236
|
+
type="file"
|
|
237
|
+
class="sr-only"
|
|
238
|
+
tabindex="-1"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
:accept="options.accept"
|
|
241
|
+
@change="attach"
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</XqFieldWrapper>
|
|
245
|
+
</template>
|
|
@@ -8,9 +8,9 @@ import XqRadioGroupItem from '../../ui/XqRadioGroupItem.vue';
|
|
|
8
8
|
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{ node: SchemaNode }>();
|
|
11
|
-
const { value, error, describedBy, required, disabled, id } = useField<
|
|
12
|
-
|
|
13
|
-
);
|
|
11
|
+
const { value, error, label, describedBy, required, disabled, id } = useField<
|
|
12
|
+
string | number | null
|
|
13
|
+
>(props.node);
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<template>
|
|
@@ -22,7 +22,7 @@ const { value, error, describedBy, required, disabled, id } = useField<string |
|
|
|
22
22
|
:aria-invalid="error ? true : undefined"
|
|
23
23
|
:aria-required="required || undefined"
|
|
24
24
|
:aria-describedby="describedBy"
|
|
25
|
-
:aria-labelledby="
|
|
25
|
+
:aria-labelledby="label ? `${id}-label` : undefined"
|
|
26
26
|
:class="cn(node.props?.inline && 'gap-x-6 gap-y-2 flex flex-wrap')"
|
|
27
27
|
>
|
|
28
28
|
<div
|
|
@@ -30,7 +30,11 @@ const { value, error, describedBy, required, disabled, id } = useField<string |
|
|
|
30
30
|
:key="String(option.value)"
|
|
31
31
|
class="gap-2.5 flex items-start"
|
|
32
32
|
>
|
|
33
|
-
<XqRadioGroupItem
|
|
33
|
+
<XqRadioGroupItem
|
|
34
|
+
:id="`${id}-${index}`"
|
|
35
|
+
:value="option.value"
|
|
36
|
+
:disabled="option.disabled"
|
|
37
|
+
/>
|
|
34
38
|
<div class="gap-1 grid">
|
|
35
39
|
<XqLabel :for="`${id}-${index}`" class="leading-4 font-normal cursor-pointer">{{
|
|
36
40
|
option.label
|