@xelqun/forms 0.1.1 → 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,110 @@
|
|
|
1
|
+
export interface ImageResize {
|
|
2
|
+
ratio?: string | null;
|
|
3
|
+
mode?: 'force' | 'cover' | 'contain' | null;
|
|
4
|
+
width?: number | null;
|
|
5
|
+
height?: number | null;
|
|
6
|
+
upscale?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ImageFrame {
|
|
10
|
+
/** The part of the source to draw. */
|
|
11
|
+
crop: { x: number; y: number; width: number; height: number };
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseRatio(ratio: string | null | undefined): number | null {
|
|
17
|
+
const [width, height] = (ratio ?? '').split(':').map(Number);
|
|
18
|
+
|
|
19
|
+
return width > 0 && height > 0 ? width / height : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The middle of the source at this shape. */
|
|
23
|
+
function centerCrop(sourceWidth: number, sourceHeight: number, ratio: number): ImageFrame['crop'] {
|
|
24
|
+
const width = Math.min(sourceWidth, sourceHeight * ratio);
|
|
25
|
+
const height = width / ratio;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
x: (sourceWidth - width) / 2,
|
|
29
|
+
y: (sourceHeight - height) / 2,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What to draw, and at what size. Works on numbers alone, so the rules can be
|
|
37
|
+
* tested without a canvas.
|
|
38
|
+
*/
|
|
39
|
+
export function computeFrame(
|
|
40
|
+
sourceWidth: number,
|
|
41
|
+
sourceHeight: number,
|
|
42
|
+
{ ratio, mode, width, height, upscale = true }: ImageResize,
|
|
43
|
+
): ImageFrame {
|
|
44
|
+
const shape = parseRatio(ratio);
|
|
45
|
+
let crop = shape
|
|
46
|
+
? centerCrop(sourceWidth, sourceHeight, shape)
|
|
47
|
+
: { x: 0, y: 0, width: sourceWidth, height: sourceHeight };
|
|
48
|
+
|
|
49
|
+
if (!width && !height) {
|
|
50
|
+
return { crop, width: Math.round(crop.width), height: Math.round(crop.height) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const aspect = crop.width / crop.height;
|
|
54
|
+
const boxWidth = width ?? height! * aspect;
|
|
55
|
+
const boxHeight = height ?? width! / aspect;
|
|
56
|
+
|
|
57
|
+
if (mode === 'cover') {
|
|
58
|
+
const inner = centerCrop(crop.width, crop.height, boxWidth / boxHeight);
|
|
59
|
+
|
|
60
|
+
crop = { ...inner, x: crop.x + inner.x, y: crop.y + inner.y };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mode === 'force' || mode === 'cover') {
|
|
64
|
+
const outputWidth = upscale ? boxWidth : Math.min(boxWidth, crop.width);
|
|
65
|
+
const outputHeight = upscale ? boxHeight : Math.min(boxHeight, crop.height);
|
|
66
|
+
|
|
67
|
+
return { crop, width: Math.round(outputWidth), height: Math.round(outputHeight) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fit = Math.min(boxWidth / crop.width, boxHeight / crop.height);
|
|
71
|
+
const scale = upscale ? fit : Math.min(fit, 1);
|
|
72
|
+
|
|
73
|
+
return { crop, width: Math.round(crop.width * scale), height: Math.round(crop.height * scale) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Crop and resize a picked image in the browser, before it uploads. Anything
|
|
78
|
+
* this can't redraw goes up as it is: an animated GIF would lose its frames.
|
|
79
|
+
*/
|
|
80
|
+
export async function prepareImage(
|
|
81
|
+
file: File,
|
|
82
|
+
resize: ImageResize | null | undefined,
|
|
83
|
+
): Promise<File> {
|
|
84
|
+
if (!resize || !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sent as it is, a file the browser can't read gets a proper reason from the server.
|
|
89
|
+
const bitmap = await createImageBitmap(file).catch(() => null);
|
|
90
|
+
|
|
91
|
+
if (!bitmap) {
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { crop, width, height } = computeFrame(bitmap.width, bitmap.height, resize);
|
|
96
|
+
const canvas = document.createElement('canvas');
|
|
97
|
+
|
|
98
|
+
canvas.width = width;
|
|
99
|
+
canvas.height = height;
|
|
100
|
+
canvas
|
|
101
|
+
.getContext('2d')
|
|
102
|
+
?.drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, width, height);
|
|
103
|
+
bitmap.close();
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
canvas.toBlob((blob) => {
|
|
107
|
+
resolve(blob ? new File([blob], file.name, { type: file.type }) : file);
|
|
108
|
+
}, file.type);
|
|
109
|
+
});
|
|
110
|
+
}
|
package/resources/js/core/log.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { reactive } from 'vue';
|
|
2
2
|
import type { UpdatePatch } from '../schema/types';
|
|
3
3
|
|
|
4
|
-
export type RequestKind = 'update' | 'search' | 'upload';
|
|
4
|
+
export type RequestKind = 'update' | 'search' | 'upload' | 'validate' | 'action';
|
|
5
5
|
|
|
6
6
|
export interface RequestLogEntry {
|
|
7
7
|
id: number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface TextEdit {
|
|
2
|
+
text: string;
|
|
3
|
+
selectionStart: number;
|
|
4
|
+
selectionEnd: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Put marks around the selection, keeping it selected.
|
|
9
|
+
*/
|
|
10
|
+
export function wrapSelection(
|
|
11
|
+
text: string,
|
|
12
|
+
start: number,
|
|
13
|
+
end: number,
|
|
14
|
+
before: string,
|
|
15
|
+
after: string,
|
|
16
|
+
placeholder = '',
|
|
17
|
+
): TextEdit {
|
|
18
|
+
const inner = text.slice(start, end) || placeholder;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
text: `${text.slice(0, start)}${before}${inner}${after}${text.slice(end)}`,
|
|
22
|
+
selectionStart: start + before.length,
|
|
23
|
+
selectionEnd: start + before.length + inner.length,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start every selected line with a prefix. "#" numbers them, for an ordered list.
|
|
29
|
+
*/
|
|
30
|
+
export function prefixLines(text: string, start: number, end: number, prefix: string): TextEdit {
|
|
31
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
32
|
+
const nextBreak = text.indexOf('\n', end);
|
|
33
|
+
const lineEnd = nextBreak === -1 ? text.length : nextBreak;
|
|
34
|
+
const lines = text
|
|
35
|
+
.slice(lineStart, lineEnd)
|
|
36
|
+
.split('\n')
|
|
37
|
+
.map((line, index) => (prefix === '#' ? `${index + 1}. ${line}` : `${prefix}${line}`))
|
|
38
|
+
.join('\n');
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
text: `${text.slice(0, lineStart)}${lines}${text.slice(lineEnd)}`,
|
|
42
|
+
selectionStart: lineStart,
|
|
43
|
+
selectionEnd: lineStart + lines.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a block on lines of its own.
|
|
49
|
+
*/
|
|
50
|
+
export function insertBlock(text: string, position: number, block: string): TextEdit {
|
|
51
|
+
const before = text.slice(0, position);
|
|
52
|
+
const lead =
|
|
53
|
+
before === '' || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
|
|
54
|
+
const inserted = `${lead}${block}\n`;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
text: `${before}${inserted}${text.slice(position)}`,
|
|
58
|
+
selectionStart: position + inserted.length,
|
|
59
|
+
selectionEnd: position + inserted.length,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const TOKENS: Record<string, RegExp> = {
|
|
2
|
+
'9': /\d/,
|
|
3
|
+
a: /[a-zA-Z]/,
|
|
4
|
+
'*': /[a-zA-Z0-9]/,
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fit a value to a mask: 9 is a digit, a is a letter, * is either, and anything
|
|
9
|
+
* else is typed for the user. It stops where the value runs out, so a separator
|
|
10
|
+
* only appears once something follows it and backspace can remove it.
|
|
11
|
+
*/
|
|
12
|
+
export function applyMask(value: string, mask: string): string {
|
|
13
|
+
let output = '';
|
|
14
|
+
let source = 0;
|
|
15
|
+
|
|
16
|
+
for (const character of mask) {
|
|
17
|
+
const token = TOKENS[character];
|
|
18
|
+
|
|
19
|
+
if (!token) {
|
|
20
|
+
if (source >= value.length) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
output += character;
|
|
25
|
+
source += value[source] === character ? 1 : 0;
|
|
26
|
+
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
while (source < value.length && !token.test(value[source])) {
|
|
31
|
+
source++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (source >= value.length) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
output += value[source++];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
@@ -20,7 +20,10 @@ export function mergeNode(target: Record<string, any>, patch: Record<string, any
|
|
|
20
20
|
for (const [key, value] of Object.entries(patch)) {
|
|
21
21
|
const current = target[key];
|
|
22
22
|
|
|
23
|
-
if (
|
|
23
|
+
if (key === 'extraAttributes') {
|
|
24
|
+
// Replaced whole: an attribute the server stopped sending has to go.
|
|
25
|
+
target[key] = value;
|
|
26
|
+
} else if (isNodeList(value) && Array.isArray(current)) {
|
|
24
27
|
target[key] = mergeNodeList(current, value);
|
|
25
28
|
} else if (isPlainObject(value) && isPlainObject(current) && !Array.isArray(value)) {
|
|
26
29
|
mergeNode(current, value);
|
|
@@ -47,14 +50,14 @@ function mergeNodeList(current: SchemaNode[], incoming: SchemaNode[]): SchemaNod
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
|
-
* Visit a node and every node below it, including
|
|
53
|
+
* Visit a node and every node below it, including the rows of fields that have them (but not templates).
|
|
51
54
|
*/
|
|
52
55
|
export function eachNode(nodes: SchemaNode[], callback: (node: SchemaNode) => void): void {
|
|
53
56
|
for (const node of nodes) {
|
|
54
57
|
callback(node);
|
|
55
58
|
eachNode(node.children ?? [], callback);
|
|
56
59
|
|
|
57
|
-
if (node.
|
|
60
|
+
if (node.containsRows && isPlainObject(node.props?.rows)) {
|
|
58
61
|
for (const rowNodes of Object.values(
|
|
59
62
|
node.props!.rows as Record<string, SchemaNode[]>,
|
|
60
63
|
)) {
|
|
@@ -3,6 +3,8 @@ import { isPlainObject } from './values';
|
|
|
3
3
|
export const ROOT = 'data';
|
|
4
4
|
export const ROW_PLACEHOLDER = '{row}';
|
|
5
5
|
|
|
6
|
+
const ROW_PLACEHOLDER_PATTERN = /^\{row(:\d+)?\}$/;
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Read a value by dot path. Paths containing "*" return a flat list,
|
|
8
10
|
* matching Xelqun\Forms\Support\StatePath::get().
|
|
@@ -78,8 +80,12 @@ export function deletePath(state: Record<string, any>, path: string): void {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
function matchesAnySegment(segment: string): boolean {
|
|
84
|
+
return segment === '*' || ROW_PLACEHOLDER_PATTERN.test(segment);
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
/**
|
|
82
|
-
* Whether two paths touch the same state ("*" and
|
|
88
|
+
* Whether two paths touch the same state ("*" and row placeholders match any segment).
|
|
83
89
|
*/
|
|
84
90
|
export function overlaps(first: string, second: string): boolean {
|
|
85
91
|
const a = first.split('.');
|
|
@@ -87,16 +93,7 @@ export function overlaps(first: string, second: string): boolean {
|
|
|
87
93
|
const length = Math.min(a.length, b.length);
|
|
88
94
|
|
|
89
95
|
for (let index = 0; index < length; index++) {
|
|
90
|
-
|
|
91
|
-
const right = b[index];
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
left !== right &&
|
|
95
|
-
left !== '*' &&
|
|
96
|
-
right !== '*' &&
|
|
97
|
-
left !== ROW_PLACEHOLDER &&
|
|
98
|
-
right !== ROW_PLACEHOLDER
|
|
99
|
-
) {
|
|
96
|
+
if (a[index] !== b[index] && !matchesAnySegment(a[index]) && !matchesAnySegment(b[index])) {
|
|
100
97
|
return false;
|
|
101
98
|
}
|
|
102
99
|
}
|
|
@@ -109,10 +106,10 @@ export function startsWithPath(path: string, prefix: string): boolean {
|
|
|
109
106
|
}
|
|
110
107
|
|
|
111
108
|
/**
|
|
112
|
-
* Deep-copy a value and replace
|
|
109
|
+
* Deep-copy a value and replace one level's row placeholder in its strings.
|
|
113
110
|
*/
|
|
114
|
-
export function replaceRow<T>(value: T, rowKey: string): T {
|
|
115
|
-
return JSON.parse(JSON.stringify(value).split(
|
|
111
|
+
export function replaceRow<T>(value: T, rowKey: string, placeholder = ROW_PLACEHOLDER): T {
|
|
112
|
+
return JSON.parse(JSON.stringify(value).split(placeholder).join(rowKey)) as T;
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
export function clone<T>(value: T): T {
|