vue-pointing-bubble 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 +35 -384
- package/dist/index.d.ts +3 -44
- package/dist/pointing-bubble/PointingBubble.vue.d.ts +45 -0
- package/dist/pointing-bubble/core.d.ts +13 -0
- package/dist/pointing-bubble/createBubbleController.d.ts +31 -0
- package/dist/pointing-bubble/ease.d.ts +9 -0
- package/dist/pointing-bubble/geometry.d.ts +14 -0
- package/dist/pointing-bubble/index.d.ts +11 -0
- package/dist/pointing-bubble/measure.d.ts +5 -0
- package/dist/pointing-bubble/resolveTarget.d.ts +7 -0
- package/dist/pointing-bubble/styles.d.ts +3 -0
- package/dist/pointing-bubble/tail.d.ts +12 -0
- package/dist/pointing-bubble/types.d.ts +66 -0
- package/dist/vue-pointing-bubble.cjs +9 -0
- package/dist/vue-pointing-bubble.js +505 -310
- package/package.json +48 -51
- package/dist/vue-pointing-bubble.umd.cjs +0 -9
- package/registry/ui/pointing-bubble/PointingBubble.vue +0 -408
- package/registry/ui/pointing-bubble/index.ts +0 -1
- package/src/index.d.ts +0 -44
- package/src/index.js +0 -9
package/package.json
CHANGED
|
@@ -1,65 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vue-pointing-bubble",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vue 3 callout / speech bubble
|
|
5
|
-
"keywords": [
|
|
6
|
-
"vue",
|
|
7
|
-
"vue3",
|
|
8
|
-
"callout",
|
|
9
|
-
"speech-bubble",
|
|
10
|
-
"tooltip",
|
|
11
|
-
"popover",
|
|
12
|
-
"pointer",
|
|
13
|
-
"svg"
|
|
14
|
-
],
|
|
15
|
-
"author": "Alexey Murz Korepov",
|
|
16
|
-
"license": "MIT",
|
|
17
|
-
"homepage": "https://murznn.github.io/vue-pointing-bubble/",
|
|
18
|
-
"repository": {
|
|
19
|
-
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/MurzNN/vue-pointing-bubble.git"
|
|
21
|
-
},
|
|
22
|
-
"bugs": {
|
|
23
|
-
"url": "https://github.com/MurzNN/vue-pointing-bubble/issues"
|
|
24
|
-
},
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Vue 3 callout / speech bubble whose SVG tail points exactly at coordinates or a DOM element.",
|
|
25
5
|
"type": "module",
|
|
26
|
-
"
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/vue-pointing-bubble.cjs",
|
|
27
11
|
"module": "./dist/vue-pointing-bubble.js",
|
|
28
|
-
"unpkg": "./dist/vue-pointing-bubble.umd.cjs",
|
|
29
|
-
"jsdelivr": "./dist/vue-pointing-bubble.umd.cjs",
|
|
30
12
|
"types": "./dist/index.d.ts",
|
|
31
13
|
"exports": {
|
|
32
14
|
".": {
|
|
33
15
|
"types": "./dist/index.d.ts",
|
|
34
16
|
"import": "./dist/vue-pointing-bubble.js",
|
|
35
|
-
"require": "./dist/vue-pointing-bubble.
|
|
36
|
-
}
|
|
37
|
-
"./PointingBubble.vue": "./registry/ui/pointing-bubble/PointingBubble.vue"
|
|
38
|
-
},
|
|
39
|
-
"files": [
|
|
40
|
-
"dist",
|
|
41
|
-
"src",
|
|
42
|
-
"registry"
|
|
43
|
-
],
|
|
44
|
-
"sideEffects": false,
|
|
45
|
-
"scripts": {
|
|
46
|
-
"dev": "npm run dev:demo",
|
|
47
|
-
"dev:demo": "vite --config vite.demo.config.js",
|
|
48
|
-
"build": "vite build && cp src/index.d.ts dist/index.d.ts",
|
|
49
|
-
"build:demo": "npm run registry:build && vite build --config vite.demo.config.js",
|
|
50
|
-
"registry:build": "shadcn-vue build --output demo/public/r",
|
|
51
|
-
"preview:demo": "vite preview --config vite.demo.config.js",
|
|
52
|
-
"test": "playwright test",
|
|
53
|
-
"prepare": "npm run build"
|
|
17
|
+
"require": "./dist/vue-pointing-bubble.cjs"
|
|
18
|
+
}
|
|
54
19
|
},
|
|
55
20
|
"peerDependencies": {
|
|
56
|
-
"vue": "^3.
|
|
21
|
+
"vue": "^3.5.0"
|
|
57
22
|
},
|
|
58
23
|
"devDependencies": {
|
|
59
|
-
"@
|
|
60
|
-
"@
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"vue": "^
|
|
24
|
+
"@microsoft/api-extractor": "^7.59.4",
|
|
25
|
+
"@types/node": "^22.18.0",
|
|
26
|
+
"@vitejs/plugin-vue": "^6.0.1",
|
|
27
|
+
"@vue/test-utils": "^2.4.6",
|
|
28
|
+
"@vue/tsconfig": "^0.7.0",
|
|
29
|
+
"jsdom": "^26.1.0",
|
|
30
|
+
"typescript": "~5.9.2",
|
|
31
|
+
"unplugin-dts": "^1.0.0-beta.6",
|
|
32
|
+
"vite": "^7.1.7",
|
|
33
|
+
"vitest": "^3.2.4",
|
|
34
|
+
"vue": "^3.5.21",
|
|
35
|
+
"vue-tsc": "^3.0.7"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"homepage": "https://murznn.github.io/js-pointing-bubble/",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/MurzNN/js-pointing-bubble.git",
|
|
46
|
+
"directory": "packages/vue"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"vue",
|
|
50
|
+
"vue3",
|
|
51
|
+
"callout",
|
|
52
|
+
"speech-bubble",
|
|
53
|
+
"tooltip",
|
|
54
|
+
"popover",
|
|
55
|
+
"shadcn-vue"
|
|
56
|
+
],
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "vite build",
|
|
59
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
|
60
|
+
"test": "vitest run"
|
|
64
61
|
}
|
|
65
|
-
}
|
|
62
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require("vue")):typeof define==`function`&&define.amd?define([`exports`,`vue`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.VuePointingBubble={},e.Vue))})(this,function(e,t){Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});var n=[`data-placement`],r=[`d`,`fill`],i=[`d`,`stroke`,`stroke-width`],a=`:where(.vue-pointing-bubble__inner) {
|
|
2
|
-
box-sizing: border-box;
|
|
3
|
-
width: var(--vpb-width, max-content);
|
|
4
|
-
max-width: var(--vpb-max-width, none);
|
|
5
|
-
padding: var(--vpb-padding);
|
|
6
|
-
background: white;
|
|
7
|
-
border: 2px solid gray;
|
|
8
|
-
border-radius: 8px;
|
|
9
|
-
}`,o=`drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))`,s=30,c=280,l=[`tx`,`ty`,`bx`,`by`],u=[`tx`,`ty`],d=()=>{if(typeof document>`u`||document.getElementById(`vue-pointing-bubble-styles`))return;let e=document.createElement(`style`);e.id=`vue-pointing-bubble-styles`,e.textContent=a,document.head.prepend(e)},f=Object.assign({inheritAttrs:!1},{__name:`PointingBubble`,props:{target:{type:[Object,String,typeof Element<`u`?Element:Object],default:null},placement:{type:String,default:`auto`,validator:e=>[`auto`,`top-left`,`top-right`,`bottom-left`,`bottom-right`,`manual`].includes(e)},targetAnchor:{type:String,default:`center`,validator:e=>[`center`,`edge`].includes(e)},maxWidth:{type:Number,default:void 0},tailLength:{type:Number,default:45},tailBaseWidth:{type:Number,default:24},padding:{type:[Number,String],default:8},shadow:{type:Boolean,default:!0},transitionDuration:{type:Number,default:200}},setup(e,{expose:a}){let f=e;d();let p=(0,t.ref)(null),m=(0,t.ref)(null),h=(0,t.ref)(null),g=(0,t.ref)(null),_=(0,t.ref)(null),v=(0,t.ref)({left:0,top:0,width:0,height:0,scaleX:1,scaleY:1,viewportWidth:0,viewportHeight:0}),y=(0,t.computed)(()=>f.placement===`manual`),b=(e,t)=>!!e&&!!t&&Object.keys(e).every(n=>e[n]===t[n]),x=e=>typeof Element>`u`||!e?null:typeof e==`string`?document.querySelector(e):e instanceof Element?e:e.$el instanceof Element?e.$el:null,S=()=>{let e=p.value;if(!f.target||!e){h.value=null;return}let t=e.getBoundingClientRect(),n=e.offsetWidth?t.width/e.offsetWidth:1,r=e.offsetHeight?t.height/e.offsetHeight:1;v.value={left:t.left,top:t.top,width:e.offsetWidth,height:e.offsetHeight,scaleX:n,scaleY:r,viewportWidth:window.innerWidth,viewportHeight:window.innerHeight};let i=x(f.target);if(i){let e=i.getBoundingClientRect(),a={left:(e.left-t.left)/n,top:(e.top-t.top)/r,right:(e.right-t.left)/n,bottom:(e.bottom-t.top)/r};b(a,g.value)||(g.value=a),h.value={x:(a.left+a.right)/2,y:(a.top+a.bottom)/2}}else typeof f.target.x==`number`&&typeof f.target.y==`number`?(g.value=null,h.value={x:f.target.x,y:f.target.y}):(g.value=null,h.value=null);C()};(0,t.watch)(()=>[f.target,f.target?.x,f.target?.y],S,{flush:`post`});let C=()=>{let e=m.value;if(!e||!f.target)return;let t=getComputedStyle(e),n={x:y.value?e.offsetLeft:0,y:y.value?e.offsetTop:0,width:e.offsetWidth,height:e.offsetHeight,fill:t.backgroundColor,stroke:t.borderTopColor,strokeWidth:parseFloat(t.borderTopWidth)||0,radius:t.borderTopLeftRadius,zIndex:t.zIndex};b(n,_.value)||(_.value=n)};(0,t.onUpdated)(C);let w=(0,t.computed)(()=>{let e=_.value;if(!h.value||!e?.width||!e?.height)return null;let{width:t,height:n}=e,{x:r,y:i}=h.value,a=f.tailLength,o=v.value,s=f.targetAnchor===`edge`?g.value:null;if(y.value){if(s){let r=e.x+t/2,i=e.y+n/2;if(!(r>=s.left&&r<=s.right&&i>=s.top&&i<=s.bottom))return{placement:`manual`,tx:Math.min(s.right,Math.max(s.left,r)),ty:Math.min(s.bottom,Math.max(s.top,i)),bx:e.x,by:e.y,w:t,h:n}}return{placement:`manual`,tx:r,ty:i,bx:e.x,by:e.y,w:t,h:n}}let c=e=>s?e===`top`?s.top:s.bottom:i,l=f.placement;if(l===`auto`){let e=i>(o.height||600)/2?`top`:`bottom`,s=r>(o.width||800)/2?`left`:`right`,{scaleX:u,scaleY:d}=o;if(o.viewportHeight){let t=o.top+(c(`top`)-n-a)*d>=0,r=o.top+(c(`bottom`)+n+a)*d<=o.viewportHeight;e===`top`&&!t&&r?e=`bottom`:e===`bottom`&&!r&&t&&(e=`top`)}if(o.viewportWidth){let e=o.left+(r-t-a)*u>=0,n=o.left+(r+t+a)*u<=o.viewportWidth;s===`left`&&!e&&n?s=`right`:s===`right`&&!n&&e&&(s=`left`)}l=`${e}-${s}`}let[u,d]=l.split(`-`),p=c(u);return{placement:l,tx:r,ty:p,bx:d===`left`?r-t-a:r+a,by:u===`top`?p-n-a:p+a,w:t,h:n}}),T=(0,t.ref)(null),E=0,D=()=>typeof matchMedia<`u`&&matchMedia(`(prefers-reduced-motion: reduce)`).matches;(0,t.watch)(w,e=>{typeof cancelAnimationFrame<`u`&&cancelAnimationFrame(E);let t=T.value,n=f.transitionDuration;if(!e||!t||n<=0||typeof requestAnimationFrame>`u`||D()){T.value=e;return}let r=performance.now(),i=a=>{let o=Math.min(1,(a-r)/n),s=1-(1-o)**3,c={...e},d=e.placement===`manual`?u:l;for(let n of d)c[n]=t[n]+(e[n]-t[n])*s;T.value=c,o<1&&(E=requestAnimationFrame(i))};E=requestAnimationFrame(i)},{immediate:!0});let O=null;(0,t.onMounted)(()=>{window.addEventListener(`resize`,S),window.addEventListener(`scroll`,S,!0),typeof ResizeObserver<`u`&&(O=new ResizeObserver(S),O.observe(p.value),O.observe(m.value)),S()}),(0,t.onBeforeUnmount)(()=>{window.removeEventListener(`resize`,S),window.removeEventListener(`scroll`,S,!0),O?.disconnect(),typeof cancelAnimationFrame<`u`&&cancelAnimationFrame(E)});let k=(0,t.computed)(()=>{let e=T.value;if(!e)return null;let{placement:t,tx:n,ty:r,bx:i,by:a,w:o,h:s}=e,c=_.value.strokeWidth,l=_.value.radius,u=parseFloat(l)*(l.endsWith(`%`)?Math.min(o,s)/100:1)||0,d=f.tailBaseWidth/2,p=(e,t,n)=>t>n?(t+n)/2:Math.min(n,Math.max(t,e)),m=null,h=0;if(t===`manual`){let e=Math.max(i-n,n-(i+o),0),t=Math.max(a-r,r-(a+s),0);if(e>0&&e>=t)m=n<i?`left`:`right`,h=r;else if(t>0)m=r<a?`top`:`bottom`,h=n;else return null}else{let[e,n]=t.split(`-`);m=n===`left`?`right`:`left`,h=a+s*(e===`top`?.65:.35)}let g=(e,t)=>`${e} ${t}`,v=(e,t)=>`M ${g(...e)} L ${n} ${r} L ${g(...t)}`,y=c/2;if(m===`left`||m===`right`){let e=p(h,a+u+d,a+s-u-d),t=e-d,n=e+d,r=m===`left`?i+c+1:i+o-c-1,l=m===`left`?i+y:i+o-y;return{fill:v([r,t],[r,n]),stroke:c?v([l,t],[l,n]):null}}let b=p(h,i+u+d,i+o-u-d),x=b-d,S=b+d,C=m===`top`?a+c+1:a+s-c-1,w=m===`top`?a+y:a+s-y;return{fill:v([x,C],[S,C]),stroke:c?v([x,w],[S,w]):null}}),A=(0,t.computed)(()=>f.target?T.value?.placement:`none`),j=(0,t.computed)(()=>{if(!f.target)return{};let e=_.value?.zIndex;return{position:`absolute`,inset:0,pointerEvents:`none`,zIndex:e&&e!==`auto`?e:s,filter:f.shadow?o:void 0}}),M=(0,t.computed)(()=>{let e={"--vpb-padding":typeof f.padding==`number`?`${f.padding}px`:f.padding},t=f.maxWidth??(f.target?c:null);if(t!=null&&(e[`--vpb-max-width`]=t+`px`),!f.target)return e[`--vpb-width`]=`fit-content`,f.shadow&&(e.filter=o),e;let n=T.value;return Object.assign(e,{position:`absolute`,pointerEvents:`auto`,visibility:n?`visible`:`hidden`}),y.value||Object.assign(e,{left:(n?.bx??0)+`px`,top:(n?.by??0)+`px`,margin:0}),e});return a({update:S}),(a,o)=>((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{ref_key:`rootRef`,ref:p,class:`vue-pointing-bubble`,"data-placement":A.value,style:(0,t.normalizeStyle)(j.value)},[(0,t.createElementVNode)(`div`,(0,t.mergeProps)({ref_key:`boxRef`,ref:m,class:`vue-pointing-bubble__inner`},a.$attrs,{style:M.value}),[(0,t.renderSlot)(a.$slots,`default`,{placement:A.value,tip:w.value?{x:w.value.tx,y:w.value.ty}:h.value},()=>[o[0]||=(0,t.createElementVNode)(`p`,{style:{margin:0,fontSize:`14px`,fontWeight:600,color:`#1e293b`}},`Callout content`,-1)])],16),e.target&&k.value?((0,t.openBlock)(),(0,t.createElementBlock)(`svg`,{key:0,class:`vue-pointing-bubble__svg`,style:(0,t.normalizeStyle)({position:`absolute`,inset:0,width:`100%`,height:`100%`,overflow:`visible`,zIndex:_.value?.zIndex})},[(0,t.createElementVNode)(`path`,{class:`vue-pointing-bubble__path`,d:k.value.fill,fill:_.value.fill},null,8,r),k.value.stroke?((0,t.openBlock)(),(0,t.createElementBlock)(`path`,{key:0,class:`vue-pointing-bubble__path`,d:k.value.stroke,fill:`none`,stroke:_.value.stroke,"stroke-width":_.value.strokeWidth,"stroke-linejoin":`round`,"stroke-linecap":`butt`},null,8,i)):(0,t.createCommentVNode)(``,!0)],4)):(0,t.createCommentVNode)(``,!0)],12,n))}});e.Callout=f,e.PointingBubble=f,e.default={install(e,t={}){e.component(t.name||`PointingBubble`,f)}}});
|
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
<script>
|
|
2
|
-
// Default look of the box. `:where()` has zero specificity, so any class or style set on the component wins.
|
|
3
|
-
const CSS = `:where(.vue-pointing-bubble__inner) {
|
|
4
|
-
box-sizing: border-box;
|
|
5
|
-
width: var(--vpb-width, max-content);
|
|
6
|
-
max-width: var(--vpb-max-width, none);
|
|
7
|
-
padding: var(--vpb-padding);
|
|
8
|
-
background: white;
|
|
9
|
-
border: 2px solid gray;
|
|
10
|
-
border-radius: 8px;
|
|
11
|
-
}`
|
|
12
|
-
const SHADOW = 'drop-shadow(0 6px 8px rgba(0, 0, 0, 0.25))'
|
|
13
|
-
const DEFAULT_Z_INDEX = 30
|
|
14
|
-
const DEFAULT_MAX_WIDTH = 280
|
|
15
|
-
// In manual mode CSS owns the box, so only the tail glides.
|
|
16
|
-
const ANIMATED_KEYS = ['tx', 'ty', 'bx', 'by']
|
|
17
|
-
const MANUAL_ANIMATED_KEYS = ['tx', 'ty']
|
|
18
|
-
|
|
19
|
-
const injectStyles = () => {
|
|
20
|
-
if (typeof document === 'undefined' || document.getElementById('vue-pointing-bubble-styles')) return
|
|
21
|
-
const style = document.createElement('style')
|
|
22
|
-
style.id = 'vue-pointing-bubble-styles'
|
|
23
|
-
style.textContent = CSS
|
|
24
|
-
document.head.prepend(style)
|
|
25
|
-
}
|
|
26
|
-
</script>
|
|
27
|
-
|
|
28
|
-
<script setup>
|
|
29
|
-
/**
|
|
30
|
-
* Precision Vue 3 callout ("pointing bubble") component.
|
|
31
|
-
* The box is a regular element styled with CSS; an SVG tail is drawn from its wall,
|
|
32
|
-
* so the tip lands exactly on the target coordinate (x, y) or on a target element.
|
|
33
|
-
* Without a target, only the box is rendered, in the normal document flow.
|
|
34
|
-
*/
|
|
35
|
-
import { ref, computed, watch, onMounted, onUpdated, onBeforeUnmount } from 'vue'
|
|
36
|
-
|
|
37
|
-
// `class`, `style`, and listeners go to the box, not to the full-size overlay.
|
|
38
|
-
defineOptions({ inheritAttrs: false })
|
|
39
|
-
|
|
40
|
-
const props = defineProps({
|
|
41
|
-
// DOM element, component instance, CSS selector, or { x, y } relative to the positioned parent.
|
|
42
|
-
target: {
|
|
43
|
-
type: [Object, String, typeof Element !== 'undefined' ? Element : Object],
|
|
44
|
-
default: null
|
|
45
|
-
},
|
|
46
|
-
// 'auto' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'manual'
|
|
47
|
-
// In 'manual' mode the box is positioned by CSS (left/top) on the component.
|
|
48
|
-
placement: {
|
|
49
|
-
type: String,
|
|
50
|
-
default: 'auto',
|
|
51
|
-
validator: (v) => ['auto', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'manual'].includes(v)
|
|
52
|
-
},
|
|
53
|
-
// For element targets: 'center' points at the element's center, 'edge' at its border facing the box.
|
|
54
|
-
targetAnchor: {
|
|
55
|
-
type: String,
|
|
56
|
-
default: 'center',
|
|
57
|
-
validator: (v) => ['center', 'edge'].includes(v)
|
|
58
|
-
},
|
|
59
|
-
// Content wraps at this width (px) unless CSS on the component sets a width or max-width.
|
|
60
|
-
// Defaults to 280 with a target; without one, the box is only limited by its parent.
|
|
61
|
-
maxWidth: { type: Number, default: undefined },
|
|
62
|
-
// Distance from the box edge to the tip.
|
|
63
|
-
tailLength: { type: Number, default: 45 },
|
|
64
|
-
// Width of the tail where it attaches to the box.
|
|
65
|
-
tailBaseWidth: { type: Number, default: 24 },
|
|
66
|
-
// Used unless CSS on the component sets a padding.
|
|
67
|
-
padding: { type: [Number, String], default: 8 },
|
|
68
|
-
shadow: { type: Boolean, default: true },
|
|
69
|
-
// Duration in ms of the move animation when the target changes; 0 disables it.
|
|
70
|
-
transitionDuration: { type: Number, default: 200 }
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
injectStyles()
|
|
74
|
-
|
|
75
|
-
const rootRef = ref(null)
|
|
76
|
-
const boxRef = ref(null)
|
|
77
|
-
const tip = ref(null)
|
|
78
|
-
// Target element rect relative to the root; null for coordinate targets.
|
|
79
|
-
const targetRect = ref(null)
|
|
80
|
-
// Size and look of the box, read from the rendered element.
|
|
81
|
-
const box = ref(null)
|
|
82
|
-
// Viewport origin, local size, cumulative scale, and the viewport size.
|
|
83
|
-
const frameRect = ref({ left: 0, top: 0, width: 0, height: 0, scaleX: 1, scaleY: 1, viewportWidth: 0, viewportHeight: 0 })
|
|
84
|
-
const manual = computed(() => props.placement === 'manual')
|
|
85
|
-
|
|
86
|
-
const sameKeys = (a, b) => !!a && !!b && Object.keys(a).every((k) => a[k] === b[k])
|
|
87
|
-
|
|
88
|
-
const resolveElement = (t) => {
|
|
89
|
-
if (typeof Element === 'undefined' || !t) return null
|
|
90
|
-
// Looked up on every update, so an element that appears later is found too.
|
|
91
|
-
if (typeof t === 'string') return document.querySelector(t)
|
|
92
|
-
if (t instanceof Element) return t
|
|
93
|
-
if (t.$el instanceof Element) return t.$el
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const update = () => {
|
|
98
|
-
const root = rootRef.value
|
|
99
|
-
if (!props.target || !root) {
|
|
100
|
-
tip.value = null
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const rootRect = root.getBoundingClientRect()
|
|
105
|
-
// getBoundingClientRect is in viewport pixels, after ancestor transforms such as
|
|
106
|
-
// Slidev's scale(). left/top and the SVG path are in the root's local CSS pixels.
|
|
107
|
-
const scaleX = root.offsetWidth ? rootRect.width / root.offsetWidth : 1
|
|
108
|
-
const scaleY = root.offsetHeight ? rootRect.height / root.offsetHeight : 1
|
|
109
|
-
frameRect.value = {
|
|
110
|
-
left: rootRect.left,
|
|
111
|
-
top: rootRect.top,
|
|
112
|
-
width: root.offsetWidth,
|
|
113
|
-
height: root.offsetHeight,
|
|
114
|
-
scaleX,
|
|
115
|
-
scaleY,
|
|
116
|
-
viewportWidth: window.innerWidth,
|
|
117
|
-
viewportHeight: window.innerHeight
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const el = resolveElement(props.target)
|
|
121
|
-
if (el) {
|
|
122
|
-
const r = el.getBoundingClientRect()
|
|
123
|
-
const rect = {
|
|
124
|
-
left: (r.left - rootRect.left) / scaleX,
|
|
125
|
-
top: (r.top - rootRect.top) / scaleY,
|
|
126
|
-
right: (r.right - rootRect.left) / scaleX,
|
|
127
|
-
bottom: (r.bottom - rootRect.top) / scaleY
|
|
128
|
-
}
|
|
129
|
-
if (!sameKeys(rect, targetRect.value)) targetRect.value = rect
|
|
130
|
-
tip.value = { x: (rect.left + rect.right) / 2, y: (rect.top + rect.bottom) / 2 }
|
|
131
|
-
} else if (typeof props.target.x === 'number' && typeof props.target.y === 'number') {
|
|
132
|
-
targetRect.value = null
|
|
133
|
-
tip.value = { x: props.target.x, y: props.target.y }
|
|
134
|
-
} else {
|
|
135
|
-
// A selector that matches nothing (yet): the bubble stays hidden.
|
|
136
|
-
targetRect.value = null
|
|
137
|
-
tip.value = null
|
|
138
|
-
}
|
|
139
|
-
measure()
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
watch(() => [props.target, props.target?.x, props.target?.y], update, { flush: 'post' })
|
|
143
|
-
|
|
144
|
-
const measure = () => {
|
|
145
|
-
const el = boxRef.value
|
|
146
|
-
if (!el || !props.target) return
|
|
147
|
-
const s = getComputedStyle(el)
|
|
148
|
-
const next = {
|
|
149
|
-
// The position only matters in manual mode, where CSS places the box.
|
|
150
|
-
x: manual.value ? el.offsetLeft : 0,
|
|
151
|
-
y: manual.value ? el.offsetTop : 0,
|
|
152
|
-
width: el.offsetWidth,
|
|
153
|
-
height: el.offsetHeight,
|
|
154
|
-
fill: s.backgroundColor,
|
|
155
|
-
stroke: s.borderTopColor,
|
|
156
|
-
strokeWidth: parseFloat(s.borderTopWidth) || 0,
|
|
157
|
-
radius: s.borderTopLeftRadius,
|
|
158
|
-
zIndex: s.zIndex
|
|
159
|
-
}
|
|
160
|
-
if (!sameKeys(next, box.value)) box.value = next
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Catches changes of the component's class/style, which are not reactive in the script.
|
|
164
|
-
onUpdated(measure)
|
|
165
|
-
|
|
166
|
-
// Where the bubble should end up for the current tip, box size, and props.
|
|
167
|
-
const geometry = computed(() => {
|
|
168
|
-
const m = box.value
|
|
169
|
-
if (!tip.value || !m?.width || !m?.height) return null
|
|
170
|
-
const { width: w, height: h } = m
|
|
171
|
-
const { x: tx, y: ty } = tip.value
|
|
172
|
-
const tLen = props.tailLength
|
|
173
|
-
const f = frameRect.value
|
|
174
|
-
const rect = props.targetAnchor === 'edge' ? targetRect.value : null
|
|
175
|
-
|
|
176
|
-
if (manual.value) {
|
|
177
|
-
if (rect) {
|
|
178
|
-
// The point of the element's border nearest to the box center, unless that center is over the element.
|
|
179
|
-
const cx = m.x + w / 2
|
|
180
|
-
const cy = m.y + h / 2
|
|
181
|
-
const inside = cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom
|
|
182
|
-
if (!inside) {
|
|
183
|
-
const ex = Math.min(rect.right, Math.max(rect.left, cx))
|
|
184
|
-
const ey = Math.min(rect.bottom, Math.max(rect.top, cy))
|
|
185
|
-
return { placement: 'manual', tx: ex, ty: ey, bx: m.x, by: m.y, w, h }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return { placement: 'manual', tx, ty, bx: m.x, by: m.y, w, h }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// With the edge anchor the tip moves to the middle of the element's top or bottom edge, facing the box.
|
|
192
|
-
const tipYFor = (vertical) => (rect ? (vertical === 'top' ? rect.top : rect.bottom) : ty)
|
|
193
|
-
|
|
194
|
-
let placement = props.placement
|
|
195
|
-
if (placement === 'auto') {
|
|
196
|
-
let vertical = ty > (f.height || 600) / 2 ? 'top' : 'bottom'
|
|
197
|
-
let horizontal = tx > (f.width || 800) / 2 ? 'left' : 'right'
|
|
198
|
-
|
|
199
|
-
// Flip to the other side if the bubble would leave the viewport and fits there.
|
|
200
|
-
// Tip, box, and tail are local pixels; multiply by the scale to reach the viewport.
|
|
201
|
-
const { scaleX: sx, scaleY: sy } = f
|
|
202
|
-
if (f.viewportHeight) {
|
|
203
|
-
const fitsAbove = f.top + (tipYFor('top') - h - tLen) * sy >= 0
|
|
204
|
-
const fitsBelow = f.top + (tipYFor('bottom') + h + tLen) * sy <= f.viewportHeight
|
|
205
|
-
if (vertical === 'top' && !fitsAbove && fitsBelow) vertical = 'bottom'
|
|
206
|
-
else if (vertical === 'bottom' && !fitsBelow && fitsAbove) vertical = 'top'
|
|
207
|
-
}
|
|
208
|
-
if (f.viewportWidth) {
|
|
209
|
-
const fitsLeft = f.left + (tx - w - tLen) * sx >= 0
|
|
210
|
-
const fitsRight = f.left + (tx + w + tLen) * sx <= f.viewportWidth
|
|
211
|
-
if (horizontal === 'left' && !fitsLeft && fitsRight) horizontal = 'right'
|
|
212
|
-
else if (horizontal === 'right' && !fitsRight && fitsLeft) horizontal = 'left'
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
placement = `${vertical}-${horizontal}`
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const [vertical, horizontal] = placement.split('-')
|
|
219
|
-
const tipY = tipYFor(vertical)
|
|
220
|
-
return {
|
|
221
|
-
placement,
|
|
222
|
-
tx,
|
|
223
|
-
ty: tipY,
|
|
224
|
-
bx: horizontal === 'left' ? tx - w - tLen : tx + tLen,
|
|
225
|
-
by: vertical === 'top' ? tipY - h - tLen : tipY + tLen,
|
|
226
|
-
w,
|
|
227
|
-
h
|
|
228
|
-
}
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
// What is rendered right now; eases towards `geometry` so the tail and the box move together.
|
|
232
|
-
const current = ref(null)
|
|
233
|
-
let frame = 0
|
|
234
|
-
|
|
235
|
-
const prefersReducedMotion = () =>
|
|
236
|
-
typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
237
|
-
|
|
238
|
-
watch(geometry, (to) => {
|
|
239
|
-
if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(frame)
|
|
240
|
-
const from = current.value
|
|
241
|
-
const duration = props.transitionDuration
|
|
242
|
-
if (!to || !from || duration <= 0 || typeof requestAnimationFrame === 'undefined' || prefersReducedMotion()) {
|
|
243
|
-
current.value = to
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const start = performance.now()
|
|
248
|
-
const step = (now) => {
|
|
249
|
-
const t = Math.min(1, (now - start) / duration)
|
|
250
|
-
const eased = 1 - Math.pow(1 - t, 3)
|
|
251
|
-
const next = { ...to }
|
|
252
|
-
const keys = to.placement === 'manual' ? MANUAL_ANIMATED_KEYS : ANIMATED_KEYS
|
|
253
|
-
for (const key of keys) next[key] = from[key] + (to[key] - from[key]) * eased
|
|
254
|
-
current.value = next
|
|
255
|
-
if (t < 1) frame = requestAnimationFrame(step)
|
|
256
|
-
}
|
|
257
|
-
frame = requestAnimationFrame(step)
|
|
258
|
-
}, { immediate: true })
|
|
259
|
-
|
|
260
|
-
let resizeObserver = null
|
|
261
|
-
|
|
262
|
-
onMounted(() => {
|
|
263
|
-
window.addEventListener('resize', update)
|
|
264
|
-
window.addEventListener('scroll', update, true)
|
|
265
|
-
if (typeof ResizeObserver !== 'undefined') {
|
|
266
|
-
resizeObserver = new ResizeObserver(update)
|
|
267
|
-
resizeObserver.observe(rootRef.value)
|
|
268
|
-
resizeObserver.observe(boxRef.value)
|
|
269
|
-
}
|
|
270
|
-
update()
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
onBeforeUnmount(() => {
|
|
274
|
-
window.removeEventListener('resize', update)
|
|
275
|
-
window.removeEventListener('scroll', update, true)
|
|
276
|
-
resizeObserver?.disconnect()
|
|
277
|
-
if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(frame)
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
// The tail: a triangle from the box wall facing the tip.
|
|
281
|
-
// The fill runs a pixel past the inner edge of the border, so it covers the border where the
|
|
282
|
-
// tail attaches even when page zoom snaps that edge to a different pixel.
|
|
283
|
-
// An SVG stroke is centered on its path, so the outline is drawn along the middle of the border;
|
|
284
|
-
// if it followed the fill, half of the border width would land inside the bubble.
|
|
285
|
-
const tail = computed(() => {
|
|
286
|
-
const c = current.value
|
|
287
|
-
if (!c) return null
|
|
288
|
-
const { placement, tx, ty, bx, by, w, h } = c
|
|
289
|
-
const bw = box.value.strokeWidth
|
|
290
|
-
const radius = box.value.radius
|
|
291
|
-
const r = parseFloat(radius) * (radius.endsWith('%') ? Math.min(w, h) / 100 : 1) || 0
|
|
292
|
-
const half = props.tailBaseWidth / 2
|
|
293
|
-
const clamp = (v, lo, hi) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)))
|
|
294
|
-
|
|
295
|
-
let side = null
|
|
296
|
-
let along = 0
|
|
297
|
-
if (placement === 'manual') {
|
|
298
|
-
const dx = Math.max(bx - tx, tx - (bx + w), 0)
|
|
299
|
-
const dy = Math.max(by - ty, ty - (by + h), 0)
|
|
300
|
-
if (dx > 0 && dx >= dy) {
|
|
301
|
-
side = tx < bx ? 'left' : 'right'
|
|
302
|
-
along = ty
|
|
303
|
-
} else if (dy > 0) {
|
|
304
|
-
side = ty < by ? 'top' : 'bottom'
|
|
305
|
-
along = tx
|
|
306
|
-
} else {
|
|
307
|
-
return null
|
|
308
|
-
}
|
|
309
|
-
} else {
|
|
310
|
-
const [vertical, horizontal] = placement.split('-')
|
|
311
|
-
side = horizontal === 'left' ? 'right' : 'left'
|
|
312
|
-
along = by + h * (vertical === 'top' ? 0.65 : 0.35)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const point = (x, y) => `${x} ${y}`
|
|
316
|
-
const triangle = (base1, base2) => `M ${point(...base1)} L ${tx} ${ty} L ${point(...base2)}`
|
|
317
|
-
// Center of the border. A stroke of width bw centered here stays within the border.
|
|
318
|
-
const mid = bw / 2
|
|
319
|
-
// One pixel past the inner edge. The fill matches the background, so this overlap is
|
|
320
|
-
// invisible, but it covers the border when page zoom snaps that edge off the path.
|
|
321
|
-
const cover = 1
|
|
322
|
-
|
|
323
|
-
if (side === 'left' || side === 'right') {
|
|
324
|
-
const a = clamp(along, by + r + half, by + h - r - half)
|
|
325
|
-
const y1 = a - half
|
|
326
|
-
const y2 = a + half
|
|
327
|
-
const fillX = side === 'left' ? bx + bw + cover : bx + w - bw - cover
|
|
328
|
-
const strokeX = side === 'left' ? bx + mid : bx + w - mid
|
|
329
|
-
return {
|
|
330
|
-
fill: triangle([fillX, y1], [fillX, y2]),
|
|
331
|
-
stroke: bw ? triangle([strokeX, y1], [strokeX, y2]) : null
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
const a = clamp(along, bx + r + half, bx + w - r - half)
|
|
335
|
-
const x1 = a - half
|
|
336
|
-
const x2 = a + half
|
|
337
|
-
const fillY = side === 'top' ? by + bw + cover : by + h - bw - cover
|
|
338
|
-
const strokeY = side === 'top' ? by + mid : by + h - mid
|
|
339
|
-
return {
|
|
340
|
-
fill: triangle([x1, fillY], [x2, fillY]),
|
|
341
|
-
stroke: bw ? triangle([x1, strokeY], [x2, strokeY]) : null
|
|
342
|
-
}
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
const placement = computed(() => (props.target ? current.value?.placement : 'none'))
|
|
346
|
-
|
|
347
|
-
// Without a target the root is a plain block around the box, so opacity or transforms set on it apply to the box too.
|
|
348
|
-
const rootStyle = computed(() => {
|
|
349
|
-
if (!props.target) return {}
|
|
350
|
-
const z = box.value?.zIndex
|
|
351
|
-
return {
|
|
352
|
-
position: 'absolute',
|
|
353
|
-
inset: 0,
|
|
354
|
-
pointerEvents: 'none',
|
|
355
|
-
zIndex: z && z !== 'auto' ? z : DEFAULT_Z_INDEX,
|
|
356
|
-
filter: props.shadow ? SHADOW : undefined
|
|
357
|
-
}
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
// Keys are only added when set: merged with the user's `style`, even an undefined key would win.
|
|
361
|
-
const boxStyle = computed(() => {
|
|
362
|
-
const style = {
|
|
363
|
-
'--vpb-padding': typeof props.padding === 'number' ? `${props.padding}px` : props.padding
|
|
364
|
-
}
|
|
365
|
-
const maxWidth = props.maxWidth ?? (props.target ? DEFAULT_MAX_WIDTH : null)
|
|
366
|
-
if (maxWidth != null) style['--vpb-max-width'] = maxWidth + 'px'
|
|
367
|
-
if (!props.target) {
|
|
368
|
-
// In the flow the box shrinks to its content but never overflows its parent.
|
|
369
|
-
style['--vpb-width'] = 'fit-content'
|
|
370
|
-
if (props.shadow) style.filter = SHADOW
|
|
371
|
-
return style
|
|
372
|
-
}
|
|
373
|
-
const c = current.value
|
|
374
|
-
Object.assign(style, { position: 'absolute', pointerEvents: 'auto', visibility: c ? 'visible' : 'hidden' })
|
|
375
|
-
// In manual mode the box is positioned by its own CSS; otherwise next to the tip, ignoring margins.
|
|
376
|
-
if (!manual.value) Object.assign(style, { left: (c?.bx ?? 0) + 'px', top: (c?.by ?? 0) + 'px', margin: 0 })
|
|
377
|
-
return style
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
defineExpose({ update })
|
|
381
|
-
</script>
|
|
382
|
-
|
|
383
|
-
<template>
|
|
384
|
-
<div ref="rootRef" class="vue-pointing-bubble" :data-placement="placement" :style="rootStyle">
|
|
385
|
-
<div ref="boxRef" class="vue-pointing-bubble__inner" v-bind="$attrs" :style="boxStyle">
|
|
386
|
-
<slot :placement="placement" :tip="geometry ? { x: geometry.tx, y: geometry.ty } : tip">
|
|
387
|
-
<p :style="{ margin: 0, fontSize: '14px', fontWeight: 600, color: '#1e293b' }">Callout content</p>
|
|
388
|
-
</slot>
|
|
389
|
-
</div>
|
|
390
|
-
<svg
|
|
391
|
-
v-if="target && tail"
|
|
392
|
-
class="vue-pointing-bubble__svg"
|
|
393
|
-
:style="{ position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'visible', zIndex: box?.zIndex }"
|
|
394
|
-
>
|
|
395
|
-
<path class="vue-pointing-bubble__path" :d="tail.fill" :fill="box.fill" />
|
|
396
|
-
<path
|
|
397
|
-
v-if="tail.stroke"
|
|
398
|
-
class="vue-pointing-bubble__path"
|
|
399
|
-
:d="tail.stroke"
|
|
400
|
-
fill="none"
|
|
401
|
-
:stroke="box.stroke"
|
|
402
|
-
:stroke-width="box.strokeWidth"
|
|
403
|
-
stroke-linejoin="round"
|
|
404
|
-
stroke-linecap="butt"
|
|
405
|
-
/>
|
|
406
|
-
</svg>
|
|
407
|
-
</div>
|
|
408
|
-
</template>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default as PointingBubble } from "./PointingBubble.vue"
|
package/src/index.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { DefineComponent, Plugin, ComponentPublicInstance } from 'vue'
|
|
2
|
-
|
|
3
|
-
export type PointingBubblePlacement =
|
|
4
|
-
| 'auto'
|
|
5
|
-
| 'top-left'
|
|
6
|
-
| 'top-right'
|
|
7
|
-
| 'bottom-left'
|
|
8
|
-
| 'bottom-right'
|
|
9
|
-
| 'manual'
|
|
10
|
-
|
|
11
|
-
export interface PointingBubblePoint {
|
|
12
|
-
x: number
|
|
13
|
-
y: number
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type PointingBubbleTarget =
|
|
17
|
-
| PointingBubblePoint
|
|
18
|
-
| Element
|
|
19
|
-
| ComponentPublicInstance
|
|
20
|
-
/** A CSS selector, looked up with `document.querySelector`. */
|
|
21
|
-
| string
|
|
22
|
-
| null
|
|
23
|
-
|
|
24
|
-
export interface PointingBubbleProps {
|
|
25
|
-
target?: PointingBubbleTarget
|
|
26
|
-
placement?: PointingBubblePlacement
|
|
27
|
-
targetAnchor?: 'center' | 'edge'
|
|
28
|
-
maxWidth?: number
|
|
29
|
-
tailLength?: number
|
|
30
|
-
tailBaseWidth?: number
|
|
31
|
-
padding?: number | string
|
|
32
|
-
shadow?: boolean
|
|
33
|
-
transitionDuration?: number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export declare const PointingBubble: DefineComponent<PointingBubbleProps>
|
|
37
|
-
export declare const Callout: typeof PointingBubble
|
|
38
|
-
|
|
39
|
-
export interface PointingBubblePluginOptions {
|
|
40
|
-
name?: string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
declare const plugin: Plugin<[PointingBubblePluginOptions?]>
|
|
44
|
-
export default plugin
|
package/src/index.js
DELETED