Insert scopedSlot into d3 svg using foreignObject - vue.js

I'm creating a chart component using d3js and Vue (v2). In some parts, I want to support custom user content using scoped slots (in this case, custom tooltips)
<my-chart>
<template slot="tooltip" slot-scope="{ data }">
<span>{{ data }}</span>
</template>
</my-chart>
But I'm struggling to render this using d3js on a vuejs component render function. I'm trying to do something like:
g?.append("foreignObject").html(
this.$scopedSlots["tooltip"]({
event,
}),
);
Obviously, the html method isn't appropriated. I can't find anything like this online. All other examples use the component template to insert the foreignObject and Vue component on the SVG. Nothing using d3js
EDIT
As user I refer to developers. This code is for a lib.

Just in case anyone wants to implement something like this, I manage to resolve my issue.
Background
SVG has a concept of foreignObject which allows me to inject HTML inside an SVG. The next step is, somehow, to render the Vue component to HTML.
I'm using vue2, Vuetify, and d3js v6
Rendering the component
this.$scopedSlots["tooltip"]({
event,
}),
returns a VNode[], so using Vue.extend I create a new component, instantiate it and mount it to a div inside the foreignObject like this:
// Call the scoped slot, which returns a vnode[]
const vnodes = this.$scopedSlots["tooltip"]({
event,
});
const id = "RANDOM_GENERATED_ID";
// Append a foreignObject to an g
const fo = g?.append("foreignObject");
// Append a div to the foreignObject, which will be the vue component mount point
fo?.append("xhtml:div").attr("id", id);
// Create a new component which only render our vnodes inside a div
const component = Vue.extend({
render: (h) => {
return h(
"div",
{staticClass: "foreign-object-container"}
vnodes,
);
},
});
// Create a instance of this new component
const c = new component();
// I'm using vuetify, so I inject it here
c.$vuetify = this.$vuetify;
// Mount the component. This call will render the HTML
c.$mount("#" + id);
// Get the component rect
const bbox = c?.$el.getBoundingClientRect();
// Resize the ForeignObject to match our injected component size
return fo
?.attr("width", bbox?.width ?? 0)
.attr("height", bbox?.height ?? 0);
This successfully renders our component inside an SVG using d3js. At least it appears inside the DOM.
Problems
At this point, I faced 2 new problems.
Invalid component size
After rendering the Vue component inside the foreignObject it reported width equals 0. So, based on this I used the next styles:
.foreign-object-container {
display: inline-flex;
overflow: hidden;
}
And voilá, habemus a visible Vue component.
Scroll, ForeignObject, and the old Webkit Bug
My use case is this: The chart is responsive, so it re-renders after every container resizes (with some debounce), but to prevent deformations I set a minimum width to every element. With some screen sizes, this provokes some overflow, which inserts a scrollbar (browser behavior).
This is exactly what I want. But I'm using some Vuetify components on my scopedSlot which have a position: relative style.
Enters, an old bug on WebKit (Google Chrome, Safari, and Edge Chromium). This is better explained here and here
The solution in my case is simple. As I stated before, my foreignObject was resized to match the rendered component. So, to prevent my components to be wrongly drawn, I change my styles a little bit.
.foreign-object-container {
display: inline-flex;
overflow: hidden;
position: sticky;
position: -webkit-sticky;
}
Now, my teammates can use a generic chart with scroll support and customize some pieces of it using any Vue component (at least for now )

Related

Make PrimeVue Splitter to have dynamic two-way panel size

I am using PrimeVue Splitter with Vue3 and composition API. I want to make the size of each panel dynamic , so when I click a button they return to their original size.
This is what I have so far
<button #click="fixPanels()" >fix</button>
<Splitter style="height: 100%" class="mb-3">
<SplitterPanel :size=smallPanelSize >
Panel 1
</SplitterPanel>
<SplitterPanel :size=bigPanelSize >
Panel 2
</SplitterPanel>
</Splitter>
export default {
setup(){
let smallPanelSize = ref(30)
let bigPanelSize = ref(70)
const fixPanels = ()=>{
message.value = 30;
bigPanelSize.value = 70
}
return{smallPanelSize, bigPanelSize, fixPanels}
}
}
I can resize the panels freely, but when fixPanels is clicked, they dont return to their original size. I guess I have to use two-way binding somehow or v-model but I dont know how, there is no input here. If this is impossible, can you suggest a similar resizable panels component that the panel size is dynamically controlled?
Thank you
It seems that the SplitterPanel component does not handle reactive sizes. As a result, the prefix sizes are determined when the component is loaded and cannot be reset via reactivity.
Therefore, the solution is to reload the components to force the sizes to be reset.
For this, I created a Panels component which will be used only to display the panels and its reset button. The sizes will be retrieved via props and not assigned within the component. The resize button will send an event via emit in order to signal to the parent component the will to reload the component, in order to reset the sizes.
The parent component will create the two default size variables with ref and send them to the props. The event will also be retrieved to call the fixPanels function. The only subtlety is to manage to force the reload of the Panels component. For this, I added a key value to the Panels component which will be incremented each time the fixPanels function is called. The purpose of this variable is only to make the child think that the values of the component have changed and to create the need to reload it.
Just below, the two coded components that correspond to my explanations. (I used Vue 3 with the composition API and TypeScript, but you will probably know how to convert it to Vue 3 Option, if needed)
Panels.vue
<template>
<button #click="$emit('reload')">fix</button>
<Splitter style="height: 100%" class="mb-3">
<SplitterPanel :size="smallPanelSize">
Panel 1
</SplitterPanel>
<SplitterPanel :size="bigPanelSize">
Panel 2
</SplitterPanel>
</Splitter>
</template>
<script setup lang="ts">
import Splitter from "primevue/splitter";
import SplitterPanel from "primevue/splitterpanel";
const props = defineProps<{smallPanelSize: number, bigPanelSize:number}>();
</script>
ManagePanel.vue
<script setup lang="ts">
import { ref } from "vue";
import Panels from "./components/Panels.vue";
let small = ref(30);
let big = ref(70);
let componentKey = ref(0);
function forceRender() {
componentKey.value += 1;
}
function fixPanels() {
small.value = 30;
big.value = 70;
forceRender();
}
</script>
<template>
<Panels
:smallPanelSize="small"
:bigPanelSize="big"
:key="componentKey"
#reload="fixPanels"
/>
</template>

Scroll to bottom in Ionic Vue

I develop a chat app in Ionic Vue. To always see the latest messages, I have to scoll to the bottom automatically.
For web I use vue-chat-scroll, but this does not work with Ionic Vue.
I have tried a lot of different tactics but nothing works:
var objDiv = document.getElementById("your_div");
objDiv.scrollTop = objDiv.scrollHeight;
or
this.scrollIntoView(false);
or
var element = document.getElementById("scroll");
element.scrollIntoView();
or
vue-scroll-to with a hidden anchor at the end of the view
I have also tried different methods of listing the chat messages:
a combination of IonGrid with Rows
a normal ul / li list
just with divs
Take a look at "ion-content" component, https://ionicframework.com/docs/api/content
It has many methods and events for scrolling.
You only need to call the method .scrollToBottom() on the <ion-content> element.
You can use the following approach, refering the ion-content with a ref and calling the method scrollToBottom bellow:
<template>
<ion-content ref="content">
</ion-content>
</template>
export default defineComponent({
methods: {
scrollToBottom(){
this.$refs['content'].scrollToBottom()
}
},
});
</script>

Vue3 scoped classes are not applied for elements not declared in the template

I have a single-file vue3 component.
Its template looks like this.
<template>
<div ref="elements"></div>
</template>
I have scoped styles in the same file:
<style scoped>
.el {
color: red;
}
</style>
Now I want to add element in the script.
<script>
export default {
name: "App",
mounted() {
const div = this.$refs.elements;
const el = document.createElement("div");
el.setAttribute("class", "el");
el.innerText = "Hello World!";
div.appendChild(el);
},
};
</script>
The result shows that the element is not styled according to the class in the scoped styles.
Is there a way to apply styling to the elements added through script, while keeping styles in the scope?
After some research this seems to be an answer.
Vue scoped styling is the internal Vue feature. So, there should be a way to distinguish same class names on different components. Vue does it by adding a special id to each class name. You can find it by inspecting elements in the browser (e.g. data-v-a2c3afe).
When building a component, Vue processes its template and properly tracks only nodes you have declared for rendering there. It does not know anything it might get from somewhere. It actually makes sense and pushes you to write everything you expect to see in the template, so that it does not happen that you suddenly see something wrong in the DOM (especially if you are taking someone's code).
I have rewritten code, so that I still have scoped styles, but with no elements appending from the script and the code now allows to clearly see what is being rendered. This is probably the property of any framework - it makes you to stick to some patterns, so that everyone in the team/community knows what behavior to expect and writes more consistent code.

Problem with creating a div into the document (DOM)

I have some problems with this fragment, it creates a div into the DOM so that div appears in every page as it is normal, which is an image.
How can I change it so this div is only being created when I access at this custom component (Model.vue) and no longer being visible once I'm out of the component page.
Thanks!!
container = document.createElement( 'div' );
document.body.appendChild( container );
If your HTML is predictable you should probably add it in your template (as HTML or as a component) and then show/hide it with v-if or v-show
If you need static content, for example depending on the user input, then v-html is the way.
If you need dynamic content (e.g. with event bindings), then check out render functions.
There's also dynamic component like <component :is="someComponent">, where someComponent can be either a string name of the component, or the component itself. But you should probably try the other solution first.
First of all direct DOM manipulation(Add/Del elements) is not at all preferred in vuejs. You can use v-if instead to show the element conditionally.
Or else if you still want to do direct manipulation then you can write the above lines of code in mounted hook of your model.vue component.
mounted() {
container = document.createElement('div');
container.setAttribute("id", "divId");
}
And then in before destroyed hook you can remove this element from the DOM as below.
beforeDestroy() {
var elem = document.querySelector('#divId');
elem.parentNode.removeChild(elem);
}

Vue Chart.js doesn't get initialized in Vue Tab

I've got a component with two vue-tabs, with two instances of vue-chart-js in each of it. Though they get initialized without errors, a chart in the unactive tab returns nothing, when I try to extract an image from it via document.querySelector('#mySecondChart').toDataURL(). Only when I click that tab and make it active, this chart transforms into image. The chart in the default active tab transforms into image without errors. Here's the code:
<template>
<div>
<vue-tabs>
<v-tab>
<my-first-chart-component/>
</v-tab>
<v-tab>
<my-second-chart-component/>
</v-tab>
</vue-tabs>
<button #click="extractCharts">Extract charts</button>
</div>
</template>
<script>
// imports omitted
export default {
name: 'MyParentComponent',
// data and props omitted
methods: {
extractCharts() {
let charts = document.querySelectorAll('chart')
let firstChart = charts[0].toDataURL();
let secondChart = charts[1].toDataURL();
console.log(`${firstChart} \n\n\n ${secondChart}`)
}
}
}
</script>
When I click the button without going to second tab, my method outputs the DOMString of the first chart and nothing of the second chart. Only when I visit the second tab first, and then click the button, my method returns both stringified charts. Is there any way to forcefully get second chart rendered without even activating the tab containing it?
After searching through documentation, I've discovered that the reason of the problem lies within Chart.js itself -- if the parent element containing the chart has display: none or is hidden in some other ways, the canvas rendered in it gets the height and width properties equal to 0. This may be avoided if during chart instance's initialization pass to its options the following parameters:
options: {
// other options
responsive: false,
maintainAspectRatio: true
}
Then the canvas element bound to the chart instance would keep width and height properties passed to it in markup (i.e. <canvas id="myChart" width="1137" height: "447"></canvas>) and it's display property will remain with the value of block even if the parent element is hidden.
This worked for me.
Put the code inside mounted hook.
var canvas = document.getElementById('line-chart');
canvas.removeAttribute('width');
canvas.removeAttribute('height');
canvas.setAttribute('style', 'display: block; width: 100% !important; height: auto !important;');