Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Annotated Text component #462

Merged
merged 16 commits into from
Aug 14, 2024
Merged
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/ui/src/core/templateMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import CoreLink from "../core_components/content/CoreLink.vue";
import CoreChatbot from "../core_components/content/CoreChatbot.vue";
import CoreTags from "../core_components/content/CoreTags.vue";
import CoreAvatar from "../core_components/content/CoreAvatar.vue";
import CoreAnnotatedText from "../core_components/content/CoreAnnotatedText.vue";
import CoreJsonViewer from "../core_components/content/CoreJsonViewer.vue";

// input
import CoreCheckboxInput from "../core_components/input/CoreCheckboxInput.vue";
import CoreColorInput from "../core_components/input/CoreColorInput.vue";
Expand Down Expand Up @@ -118,6 +120,7 @@ const templateMap = {
switchinput: CoreSwitchInput,
reuse: CoreReuse,
avatar: CoreAvatar,
annotatedtext: CoreAnnotatedText,
jsonviewer: CoreJsonViewer,
};

Expand Down
60 changes: 60 additions & 0 deletions src/ui/src/core_components/base/BaseControlBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<template>
<div class="BaseControlBar">
<button
v-if="props.copyStructuredContent"
class="control-button"
@click="copyToClipboard({ text: props.copyStructuredContent })"
>
Copy JSON
</button>
<button
v-if="props.copyRawContent"
class="control-button"
@click="copyToClipboard({ text: props.copyRawContent })"
>
Copy
</button>
</div>
</template>

<script setup lang="ts">
const props = defineProps<{
copyRawContent?: string;
copyStructuredContent?: string;
}>();
Comment on lines +21 to +24
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use object definition to have runtime checks

Suggested change
const props = defineProps<{
copyRawContent?: string;
copyStructuredContent?: string;
}>();
const props = defineProps({
copyRawContent: { type: String, required: false },
copyStructuredContent: { type: String, required: false },
});

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's a broader question on how we actually define props in the project. In this case, I was following how the other components defining props. My take is to stick to one approach until we decide to go with another.

Also, I'm not sure if there's practical use of runtime checks in this case. The bar is used by us as a building block, so it's not a blackbox for a general user to leave hints to.

@ramedina86 @FabienArcellier What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good suggestion but in line with @polymorpheuz I'd like to decide on the standard and then @madeindjs can migrate all in one block :)


function copyToClipboard({ text = "" }: { text?: string }) {
try {
navigator.clipboard.writeText(text);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
</script>

<style scoped>
@import "../../renderer/sharedStyles.css";

.BaseControlBar {
margin: 10px 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}

.control-button {
background-color: var(--buttonColor);
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
font-size: 11px;
margin-right: 10px;
padding: 4px 8px;

&:hover {
opacity: 0.9;
}
}
</style>
250 changes: 250 additions & 0 deletions src/ui/src/core_components/content/CoreAnnotatedText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<template>
<div class="CoreAnnotatedText">
<span
v-for="(content, i) in fields.text.value"
:key="String(content) + i"
>
<template v-if="typeof content === 'string'">{{
content
}}</template>
<span
v-if="Array.isArray(content)"
class="annotation"
:style="{ background: content[2] || generateColor(content[1]) }"
>
{{ content[0] }}
<span v-if="content[1]" class="annotation-subject">
{{ content[1] }}
</span>
</span>
</span>
<template v-if="fields.copyButtons.value === 'yes'">
<BaseControlBar
:copy-raw-content="textToString(fields.text.value)"
:copy-structured-content="stringifyData(fields.text.value)"
/>
</template>
</div>
</template>

<script lang="ts">
import { cssClasses, primaryTextColor } from "../../renderer/sharedStyleFields";
export default {
writer: {
name: "Annotated text",
description: "Shows text with annotations",
category: "Content",
fields: {
text: {
name: "KeyValue",
type: FieldType.Object,
desc: "Value array with text/annotations. Must be a JSON string or a state reference to an array.",
init: `["This ",["is", "Verb"]," some ",["annotated", "Adjective"], ["text", "Noun"]," for those of ",["you", "Pronoun"]," who ",["like", "Verb"]," this sort of ",["thing", "Noun"],". ","And here's a ",["word", "", "#faf"]," with a fancy background but no label."]`,
},
referenceColor: {
name: "Reference",
desc: "The colour to be used as reference for chroma and luminance, and as the starting point for hue rotation.",
type: FieldType.Color,
default: "#5551FF",
category: FieldCategory.Style,
},
seed: {
name: "Seed value",
desc: "Choose a different value to reshuffle colours.",
type: FieldType.Number,
default: "1",
category: FieldCategory.Style,
},
rotateHue: {
name: "Rotate hue",
desc: "If active, rotates the hue depending on the content of the string. If turned off, the reference colour is always used.",
type: FieldType.Text,
options: {
yes: "yes",
no: "no",
},
default: "yes",
category: FieldCategory.Style,
},
copyButtons: {
name: "Copy buttons",
desc: "If active, adds a control bar with both copy text and JSON buttons.",
type: FieldType.Text,
options: {
yes: "yes",
no: "no",
},
default: "no",
category: FieldCategory.Style,
},
primaryTextColor,
cssClasses,
},
previewField: "data",
},
};
</script>
<script setup lang="ts">
import { FieldCategory, FieldType } from "../../writerTypes";
import injectionKeys from "../../injectionKeys";
import { inject } from "vue";
import chroma, { Color } from "chroma-js";

const fields = inject(injectionKeys.evaluatedFields);

const COLOR_STEPS = [
{ h: -78, s: -34, l: 16 },
{ h: -61, s: -37, l: 16 },
{ h: -2, s: 0, l: 24 },
{ h: -12, s: 0, l: 29 },
{ h: 28, s: -20, l: 24 },
{ h: -61, s: -95, l: 25 },
{ h: -173, s: 0, l: 16 },
{ h: -228, s: 0, l: 22 },
{ h: 69, s: 0, l: 25 },
{ h: 70, s: 0, l: 29 },
];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think about how to extend to support more colors, maybe consider discussing this with Miffy. Right now, I'm using the Tag colors as specified in the design system.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ramedina86 I've asked Miffy a while ago on the additional colors and she pointed to the other color groups. The problem is that they don't match 😅 I was waiting for Pete being in the office to ambush him but he keeps avoiding me so far.

It seems like it's either we're going with 10 colors or an LLM is out best bet 🤣


let currentSteps = [...COLOR_STEPS];
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might wonder why am I doing that. For the tags it's perfectly fine to have the same color for for different tags. Here it's critical to not repeat the colors. More below.

let subjectColorCache = {};
let lastSeed = fields.seed.value;
Comment on lines +108 to +110
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure you don't need to make them reactive ?

Suggested change
let currentSteps = [...COLOR_STEPS];
let subjectColorCache = {};
let lastSeed = fields.seed.value;
let currentSteps = ref([...COLOR_STEPS]);
let subjectColorCache = ref({});
let lastSeed = ref(fields.seed.value);

If its not refs, Vue.js can't track them and might not re-render on update (doc)

Copy link
Collaborator Author

@polymorpheuz polymorpheuz Jul 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem that we need reactivity in this case. I am following CoreTags that seem to work in this case just as fine.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, let's not use reactivity that we don't need.


function generateColorCss(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free about moving to colorTransformers or similar (if you want).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@polymorpheuz We may choose to have a file for color transformations, since they're similar across tags and annotated text

baseColor: Color,
colorData: { h: number; s: number; l: number },
) {
let genColor = baseColor
.set(
"hsl.h",
`${Math.sign(colorData.h) == -1 ? "-" : "+"}${Math.abs(colorData.h)}`,
)
.set(
"hsl.s",
`${Math.sign(colorData.s) == -1 ? "-" : "+"}${Math.abs(colorData.s / 100.0)}`,
)
.set(
"hsl.l",
`${Math.sign(colorData.l) == -1 ? "-" : "+"}${Math.abs(colorData.l / 100.0)}`,
);

return genColor.css();
}

function calculateColorStep(s: string, stepsLength = COLOR_STEPS.length) {
let hash = (fields.seed.value * 52673) & 0xffffffff;
for (let i = 0; i < s.length; i++) {
hash = ((i + 1) * s.charCodeAt(i)) ^ (hash & 0xffffffff);
}
const step = Math.abs(hash) % stepsLength;

return step;
}

function generateColor(s: string) {
if (fields.rotateHue.value == "no") {
return fields.referenceColor.value;
}

const baseColor = chroma(fields.referenceColor.value);

if (lastSeed !== fields.seed.value) {
currentSteps = [...COLOR_STEPS];
subjectColorCache = {};
lastSeed = fields.seed.value;
}

if (subjectColorCache[s]) {
return generateColorCss(baseColor, subjectColorCache[s]);
}

// If we run out of colors, reset the list
if (currentSteps.length === 0) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a safeguard in order to keep it operational even if we ran out of colors. We shouldn't allow that though and it's better to add like +10-20 color to what we have now.

currentSteps = [...COLOR_STEPS];
}

const colorStep = calculateColorStep(s, currentSteps.length);
const colorData = currentSteps[colorStep];

subjectColorCache[s] = colorData;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we make sure to cache the initial allocation here

currentSteps.splice(colorStep, 1);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..and we get rid of that color so the random pick won't set us up to have the same color for another subject


return generateColorCss(baseColor, colorData);
}

function textToString(text: string[]) {
return text.reduce((acc, val) => {
if (typeof val === "string") {
return acc + val;
}

return acc + val[0];
}, "");
}

function stringifyData(arr: string[]) {
try {
return JSON.stringify(arr);
} catch (e) {
return arr.join("");
}
}
</script>

<style scoped>
.CoreAnnotatedText {
color: var(--primaryTextColor);
line-height: 1.8;
}

.annotation {
flex-direction: row;
align-items: center;
background: rgba(255, 164, 33, 0.4);
border-radius: 8px;
padding: 2px 8px;
overflow: hidden;
line-height: 1;
vertical-align: middle;
}

.annotation-subject {
display: inline-flex;
font-size: 10px;
margin-left: 14px;
opacity: 0.5;
position: relative;
vertical-align: middle;

&::after {
content: "";
border-left: 1px solid;
opacity: 0.1;
position: absolute;
top: 0px;
left: -9px;
height: 10px;
}
}

.controls {
margin: 10px 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}

.control-button {
background-color: var(--buttonColor);
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
font-size: 11px;
margin-right: 10px;
padding: 4px 8px;

&:hover {
opacity: 0.9;
}
}
</style>
Loading