Make Vue component reusable.

This converts <dougal-fixed-width-format/> into a more reusable
<dougal-fixed-string-decoder/> component.
This commit is contained in:
D. Berge
2023-11-08 19:16:35 +01:00
parent 873d7cfea7
commit 9f1fc3d19c
3 changed files with 510 additions and 322 deletions

View File

@@ -0,0 +1,140 @@
<template>
<v-row dense no-gutters>
<v-col cols="1">
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip outlined label small :color="colour">{{name}}</v-chip>
</v-col>
<v-col cols="2">
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
v-model.number="value.offset"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col cols="2">
<v-text-field
class="ml-3"
dense
label="Length"
type="number"
min="0"
v-model.number="value.length"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col cols="2">
<dougal-field-content-dialog
:readonly="readonly"
:value="value"
@input="$emit('input', $event)"
></dougal-field-content-dialog>
</v-col>
<v-col cols="1">
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input >>> .chunk-empty {
padding-inline: 1px;
}
.input >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
import DougalFieldContentDialog from '../fields/field-content-dialog'
export default {
name: "DougalFixedStringDecoderField",
components: {
DougalFieldContentDialog
},
props: {
value: Object,
name: String,
colour: String,
readonly: Boolean,
},
data () {
return {
name_: "",
}
},
computed: {
},
watch: {
name () {
if (this.name != this.name_) {
this.name_ = this.name;
}
},
},
methods: {
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { offset: 0, length: 0 }
});
this.fieldName = "";
}
},
reset () {
this.text_ = this.text;
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,370 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<div v-if="isMultiline"
class="multiline mb-5"
:style="multilineElementStyle"
v-html="html"
>
</div>
<v-input v-else
class="v-text-field"
:hint="hint"
persistent-hint
v-model="text_"
>
<label
class="v-label"
:class="[ $vuetify.theme.isDark && 'theme--dark', text_ && text_.length && 'v-label--active' ]"
style="left: 0px; right: auto; position: absolute;"
>{{ label }}</label>
<div class="input"
:class="isMultiline ? 'multiline' : ''"
v-html="html"
>
</div>
</v-input>
<v-container>
<dougal-fixed-string-decoder-field v-for="(field, key) in fields" :key="key"
v-model="fields[key]"
:name="key"
:colour="getHSLColourFor(key)"
:readonly="readonly"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this property"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-decoder-field>
<v-row dense no-gutters v-if="editableFieldList && !readonly">
<v-col cols="3">
<v-text-field
label="Add new field"
hint="Enter the name of a new field"
:error-messages="fieldNameErrors"
v-model="fieldName"
append-outer-icon="mdi-plus-circle"
@keydown.enter.prevent="addField"
>
<template v-slot:append-outer>
<v-icon
color="primary"
:disabled="fieldName && !!fieldNameErrors"
@click="addField"
>mdi-plus</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.multiline {
font-family: mono;
white-space: pre;
overflow-x: auto;
overflow-y: auto;
}
.multiline >>> .line-number {
display: inline-block;
font-size: 75%;
width: 5ex;
margin-inline-end: 1ex;
text-align: right;
border: none;
position: relative;
top: -1px;
}
.input, .multiline >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
import DougalFixedStringDecoderField from './fixed-string-decoder-field'
export default {
name: "DougalFixedStringDecoder",
components: {
DougalFixedStringDecoderField
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
text: String,
fields: Object,
multiline: Boolean,
numberedLines: [ Boolean, Number ],
maxHeight: String,
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String,
label: String,
hint: String,
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The name of a new field to add.
fieldName: ""
}
},
computed: {
/** Whether to treat the sample text as multiline.
*/
isMultiline () {
return this.multiline === true || this.text.includes("\n");
},
/* Return the fields as an array sorted by offset
*/
parts () {
return Object.entries(this.fields).sort( (a, b) => a.offset - b.offset );
},
/* Transform this.parts into {start, end} intervals.
*/
chunks () {
const chunks = [];
const chunk_num = 0;
for (const [name, part] of this.parts) {
const chunk = {};
chunk.start = part.offset;
chunk.end = part.offset + part.length - 1;
//chunk.text = this.text_.slice(chunk.start, chunk.end);
chunk.colour = this.getHSLColourFor(name)
chunks.push(chunk);
}
return chunks;
},
multilineElementStyle () {
if (this.maxHeight) {
return `max-height: ${this.maxHeight};`;
}
return "";
},
/** Return a colourised HTML version of this.text.
*/
html () {
if (!this.text_) {
return;
}
if (this.isMultiline) {
if (typeof this.numberedLines == "number" || this.numberedLines) {
const offset = typeof this.numberedLines == "number" ? Math.abs(this.numberedLines) : 0;
return this.text_.split("\n").map( (line, idx) =>
this.numberLine(offset+idx, this.renderTextLine(line))).join("<br/>");
} else {
return this.text_.split("\n").map(this.renderTextLine).join("<br/>");
}
} else {
return this.renderTextLine(this.text_);
}
},
fieldNameErrors () {
return this.parts.find( i => i[0] == this.fieldName )
? "A field with this name already exists"
: null;
}
},
watch: {
text () {
if (this.text != this.text_) {
this.reset();
}
}
},
methods: {
addField () {
if (!this.fieldNameErrors) {
this.$emit("update:fields", {
...this.fields,
[this.fieldName]: { offset: 0, length: 0 }
});
this.fieldName = "";
}
},
// NOTE Not used
updateField (field, key, value) {
const fields = {
...this.fields,
[field]: {
...this.fields[field],
[key]: value
}
};
this.$emit("update:fields", fields);
},
removeField (key) {
const fields = {...this.fields};
delete fields[key];
this.$emit("update:fields", fields);
},
/** Return an HSL colour as a function of an input value
* `str`.
*/
xgetHSLColourFor () {
console.log("WILL BE DEFINED ON MOUNT");
},
/** Return a `<span>` opening tag.
*/
style (name, colour) {
return colour
? `<span class="${name}" style="color:${colour};border-color:${colour}">`
: `<span class="${name}">`;
},
/** Return an array of the intervals that intersect `pos`.
* May be empty.
*/
chunksFor (pos) {
return this.chunks.filter( chunk =>
pos >= chunk.start &&
pos <= chunk.end
)
},
/*
* Algorithm:
*
* Go through every character of one line of text and determine in which
* part(s) it falls in, if any. Collect adjacent same parts into <span/>
* elements.
*/
renderTextLine (text) {
const parts = [];
let prevStyle;
for (const pos in text) {
const chunks = this.chunksFor(pos);
const isEmpty = chunks.length == 0;
const isOverlap = chunks.length > 1;
const style = isEmpty
? this.style("chunk-empty")
: isOverlap
? this.style("chunk-overlap")
: this.style("chunk", chunks[0].colour);
if (style != prevStyle) {
if (prevStyle) {
parts.push("</span>");
}
parts.push(style);
}
parts.push(text[pos]);
prevStyle = style;
}
if (parts.length) {
parts.push("</span>");
}
return parts.join("");
},
numberLine (number, line) {
return `<span class="line-number">${number}</span>${line}`;
},
setText (v) {
//console.log(v);
this.text_ = v;
},
reset () {
this.text_ = this.text.replaceAll("\r", "");
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -1,322 +0,0 @@
<template>
<v-card flat>
<v-card-text>
<div class="sample" v-html="sampleHtml" ref="sample">
</div>
<v-divider></v-divider>
<v-form>
<v-container>
<v-row no-gutters v-for="(field, key) in {line, point, easting, northing}" :key="key">
<v-col>
<v-chip outlined :color="field.colour">{{field.label}}</v-chip>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
:ref="key"
v-model.number="field.offset"
></v-text-field>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="Width"
type="number"
min="0"
v-model.number="field.width"
></v-text-field>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
.sample {
font-family: mono;
white-space: pre;
overflow-x: auto;
overflow-y: hidden;
}
</style>
<script>
function arraysAreEqual(arr0, arr1) {
return (arr0 && arr1 && arr0.length == arr1.length) &&
arr0.reduce( (acc, cur, idx) =>
acc && (arr1[idx] == cur), true );
}
function repeatsLength(arr, comparator=(a, b) => a == b) {
function checkRepeat(arr) {
let idx = 0;
if (arr.length) {
while (comparator(arr[idx], arr[++idx]));
}
return idx;
}
const repeats = [];
let offset = 0;
let count;
while (count = checkRepeat(arr.slice(offset))) {
repeats.push(count);
offset += count;
}
return repeats;
}
export default {
name: "DougalProjectSettingsFixedWidthFormat",
props: [ "value", "sample" ],
data () {
return {
line: {
name: "line_name",
type: "int",
label: "Line",
offset: 0,
width: 4,
colour: "green"
},
point: {
name: "point_number",
type: "int",
label: "Point",
offset: 4,
width: 4,
colour: "blue"
},
easting: {
name: "easting",
type: "float",
label: "Easting",
offset: 8,
width: 12,
colour: "red"
},
northing: {
name: "northing",
type: "float",
label: "Northing",
offset: 20,
width: 12,
colour: "orange"
}
}
},
watch: {
value (newValue) {
this.reset();
},
fields: {
handler () {
if (this.overlappingFields) {
this.$emit("input", null);
} else {
this.$emit("input", {
names: this.names,
types: this.types,
widths: this.widths
});
}
},
deep: true
}
},
computed: {
sampleHtml () {
if (!this.sample) {
return "";
}
const parts = this.fieldsDescending;
const partsForColumn = (col) => {
return parts.filter(part =>
part.offset <= col && part.offset+part.width > col
);
}
const partsForLine = (line) => {
return Array.from({length: line.length}, (_, idx) => partsForColumn(idx));
}
function getDecorators(arr, comparator, specialValue) {
const repeats = repeatsLength(arr, comparator);
let sum = 0;
const res = [];
repeats.slice(0, -1).forEach( count => {
const idx = sum;
sum += count;
const el = arr[idx];
if (el.length) {
if (el.length == 1) {
// Only one field, return it
res.push({
...el[0],
offset: idx,
width: Math.min(Math.min(el[0].offset+el[0].width, sum+idx) - idx, sum-idx)
});
} else {
// More than one element, return special value
res.push({
...specialValue,
offset: idx,
width: sum-idx
});
}
}
});
return res.sort( (a, b) => b.offset-a.offset );
}
const lines = this.sample.split("\n").map( line => {
const decorators = getDecorators(partsForLine(line), arraysAreEqual, {name: "Overlap", colour: "grey"});
let s = line;
for (const part of decorators) {
const s0 = s.slice(0, part.offset);
const s1 = s.slice(part.offset, part.offset+part.width);
const s2 = s.slice(part.offset+part.width);
s = s0+`<span class="${part.colour}--text" title="${part.name}" style="border: 1px solid;">${s1}</span>${s2}`;
}
return s;
});
return lines.join("<br/>");
},
overlappingFields () {
function isOverlapping (a, b) {
return a.offset < b.offset+b.width && b.offset < a.offset+a.width;
}
for (const field of this.fields) {
for (const otherField of this.fields) {
if (field != otherField && isOverlapping(field, otherField)) {
return true;
}
}
}
return false;
},
names () {
return this.fieldsAscending.map(f => f.name);
},
types () {
return this.fieldsAscending.map(f => f.type);
},
widths () {
if (this.overlappingFields) {
return [];
}
const fields = this.fieldsAscending;
const w = [ ];
if (fields[0].offset) {
w.push(-fields[0].offset);
}
w.push(fields[0].width);
for (let idx=1; idx<fields.length; idx++) {
const f0 = fields[idx-1];
const f1 = fields[idx];
const gap = f0.offset+f0.width-f1.offset;
if (gap) {
w.push(gap);
}
w.push(f1.width);
}
return w;
},
fields () {
return [ this.line, this.point, this.easting, this.northing ];
},
fieldsAscending () {
return Object.values(this.fields).sort( (a, b) => a.offset - b.offset );
},
fieldsDescending () {
return Object.values(this.fields).sort( (a, b) => b.offset - a.offset );
}
},
methods: {
reset () {
if (!this.value) {
return;
}
const fieldFor = (index) => {
return this.fields.find(field => field.name == this.value.names[index]);
}
let offset=0;
let index=0;
for (const width of this.value.widths) {
if (width < 0) {
offset -= width;
} else {
fieldFor(index).offset = offset;
fieldFor(index).width = width;
offset += width;
index++;
}
}
},
save () {
this.$emit('input', {...this.$data.values});
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
document.addEventListener("selectionchange", this.handleSelection);
},
beforeUnmount () {
document.removeEventListener("selectionchange", this.handleSelection);
}
}
</script>