Skip to content

Commit 0be5001

Browse files
authored
fix tooltips (#389)
* fix tooltips * various fixes and improvements * fixes * ok i think we're good now * fix build * fix some css * fix * please let this be the end of it
1 parent 39ec370 commit 0be5001

File tree

8 files changed

+265
-89
lines changed

8 files changed

+265
-89
lines changed

apps/svelte.dev/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"@supabase/supabase-js": "^2.43.4",
6565
"@sveltejs/adapter-vercel": "^5.4.3",
6666
"@sveltejs/enhanced-img": "^0.3.4",
67-
"@sveltejs/kit": "^2.6.3",
67+
"@sveltejs/kit": "^2.7.0",
6868
"@sveltejs/site-kit": "workspace:*",
6969
"@sveltejs/vite-plugin-svelte": "4.0.0-next.6",
7070
"@types/cookie": "^0.6.0",

packages/site-kit/src/lib/components/Text.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
margin-top: 5rem;
100100
}
101101
102-
code,
102+
code:not(pre *),
103103
kbd {
104104
white-space: pre-wrap;
105105
padding: 0.2rem 0.4rem;
Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
<script lang="ts">
22
import { tick } from 'svelte';
3+
import Text from '../components/Text.svelte';
34
4-
let {
5-
html = '',
6-
x = 0,
7-
y = 0,
8-
onmouseenter,
9-
onmouseleave
10-
}: {
11-
html?: string;
12-
x?: number;
13-
y?: number;
14-
onmouseenter?: (event: any) => void;
15-
onmouseleave?: (event: any) => void;
16-
} = $props();
17-
18-
let width = $state(1);
19-
let tooltip = $state() as HTMLDivElement | undefined;
5+
interface Props {
6+
html: string;
7+
x: number;
8+
y: number;
9+
onmouseenter: (event: any) => void;
10+
onmouseleave: (event: any) => void;
11+
}
12+
13+
let { html, x, y, onmouseenter, onmouseleave }: Props = $props();
14+
15+
let visible = $state(false);
16+
let tooltip: HTMLDivElement;
17+
let offset = $state(0);
18+
19+
// container starts out at maxium size, then shrinks to prevent page scrolling to the right
20+
let width = $state('calc(100vw - 2 * var(--sk-page-padding-side))');
2021
2122
// bit of a gross hack but it works — this prevents the
2223
// tooltip from disappearing off the side of the screen
2324
$effect(() => {
24-
if (html && tooltip) {
25-
tick().then(() => {
26-
width = tooltip!.getBoundingClientRect().width;
27-
});
28-
}
25+
(async () => {
26+
// first, measure the window with the tooltip hidden
27+
const window_width = window.innerWidth;
28+
29+
// then, display the tooltip
30+
visible = true;
31+
await tick();
32+
33+
// then, figure out how much padding we need
34+
const container_width = parseFloat(getComputedStyle(tooltip.parentElement!).width);
35+
const padding = (window_width - container_width) / 2;
36+
37+
// then, calculate the necessary offset to ensure the
38+
// right edge of the tooltip is inside the padding
39+
const rect = tooltip.getBoundingClientRect();
40+
offset = Math.min(window_width - padding - rect.right, -20);
41+
42+
width = rect.width + 'px';
43+
})();
2944
});
3045
</script>
3146

@@ -34,35 +49,63 @@
3449
{onmouseleave}
3550
role="tooltip"
3651
class="tooltip-container"
52+
class:visible
53+
style:width
3754
style:left="{x}px"
3855
style:top="{y}px"
39-
style:--offset="{Math.min(-10, window.innerWidth - (x + width + 10))}px"
56+
style:--offset="{offset}px"
4057
>
4158
<div bind:this={tooltip} class="tooltip">
42-
<span>{@html html}</span>
59+
<Text>
60+
<span>{@html html}</span>
61+
</Text>
4362
</div>
4463
</div>
4564

4665
<style>
4766
.tooltip-container {
48-
--bg: var(--sk-theme-2);
49-
--arrow-size: 0.4rem;
67+
--bg: var(--sk-back-2);
68+
--arrow-size: 0.6rem;
69+
display: none;
5070
position: absolute;
5171
transform: translate(var(--offset), calc(2rem + var(--arrow-size)));
72+
z-index: 2;
73+
filter: var(--sk-shadow);
74+
75+
&.visible {
76+
display: block;
77+
}
5278
}
5379
5480
.tooltip {
55-
margin: 0 2rem 0 0;
5681
background-color: var(--bg);
57-
color: #fff;
5882
text-align: left;
59-
padding: 0.4rem 0.6rem;
83+
padding: 1.6rem;
6084
border-radius: var(--sk-border-radius);
61-
font-family: var(--sk-font-family-mono);
62-
font-size: 1.2rem;
63-
white-space: pre-wrap;
64-
z-index: 100;
65-
filter: drop-shadow(2px 4px 6px #67677866);
85+
font: var(--sk-font-body-small);
86+
display: inline-block;
87+
max-width: min(var(--sk-page-content-width), calc(100vw - 2 * var(--sk-page-padding-side)));
88+
max-height: 30rem;
89+
overflow-y: auto;
90+
91+
:global {
92+
p,
93+
ol,
94+
ul {
95+
font: var(--sk-font-body-small);
96+
}
97+
98+
.tags {
99+
display: grid;
100+
grid-template-columns: 8rem 1fr;
101+
align-items: baseline;
102+
103+
.tag,
104+
.param {
105+
font: var(--sk-font-mono);
106+
}
107+
}
108+
}
66109
}
67110
68111
.tooltip::after {
@@ -74,8 +117,9 @@
74117
border-bottom-color: var(--bg);
75118
}
76119
77-
.tooltip :global(a) {
78-
color: white;
79-
text-decoration: underline;
120+
span {
121+
display: flex;
122+
flex-direction: column;
123+
gap: 1rem;
80124
}
81125
</style>

packages/site-kit/src/lib/docs/hover.ts

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,87 @@
11
import { mount, onMount, unmount } from 'svelte';
22
import Tooltip from './Tooltip.svelte';
33

4+
const CLASSNAME = 'highlight';
5+
46
export function setupDocsHovers() {
57
onMount(() => {
68
let tooltip: any;
9+
let hovered: HTMLSpanElement | null = null;
710
let timeout: NodeJS.Timeout;
811

12+
function clear() {
13+
if (!tooltip) return;
14+
15+
unmount(tooltip);
16+
hovered?.classList.remove(CLASSNAME);
17+
tooltip = hovered = null;
18+
}
19+
920
function over(event: MouseEvent) {
10-
const target = event.target as HTMLElement;
21+
if (event.buttons !== 0) return; // probably selecting
22+
23+
let target = event.target as HTMLSpanElement;
24+
25+
if (!target.classList?.contains('twoslash-hover')) {
26+
return;
27+
}
28+
29+
clearTimeout(timeout);
1130

12-
if (target.classList?.contains('twoslash-hover')) {
13-
clearTimeout(timeout);
31+
if (target === hovered) return;
1432

15-
if (tooltip) {
16-
unmount(tooltip);
17-
tooltip = null;
33+
clear();
34+
35+
const container = target.querySelector('.twoslash-popup-container')!;
36+
37+
const code = container.querySelector('.twoslash-popup-code pre code');
38+
if (code && code.children.length === 2) {
39+
// for reasons I don't really understand, generated types are duplicated.
40+
// this is the easiest way to fix it
41+
const [a, b] = code.children;
42+
if (a.outerHTML === b.outerHTML) {
43+
b.remove();
1844
}
45+
}
1946

20-
const rect = target?.getBoundingClientRect();
21-
const html = target?.innerHTML;
47+
const html = container.innerHTML;
2248

49+
if (html) {
50+
const rect = target.getBoundingClientRect();
2351
const x = (rect.left + rect.right) / 2 + window.scrollX;
2452
const y = rect.top + window.scrollY;
2553

26-
if (html) {
27-
tooltip = mount(Tooltip, {
28-
target: document.body,
29-
props: {
30-
html,
31-
x,
32-
y,
33-
onmouseenter: () => {
34-
clearTimeout(timeout);
35-
},
36-
onmouseleave: () => {
37-
clearTimeout(timeout);
38-
unmount(tooltip);
39-
tooltip = null;
40-
}
54+
tooltip = mount(Tooltip, {
55+
target: document.body,
56+
props: {
57+
html,
58+
x,
59+
y,
60+
onmouseenter: () => {
61+
clearTimeout(timeout);
62+
},
63+
onmouseleave: () => {
64+
clearTimeout(timeout);
65+
timeout = setTimeout(clear, 0);
4166
}
42-
});
43-
}
67+
}
68+
});
69+
70+
hovered = target;
71+
hovered.classList.add(CLASSNAME);
4472
}
4573
}
4674

4775
function out(event: MouseEvent) {
48-
const target = event.target as HTMLElement;
49-
if (target.classList?.contains('twoslash-hover')) {
50-
timeout = setTimeout(() => {
51-
unmount(tooltip);
52-
tooltip = null;
53-
}, 200);
76+
let target = event.target as HTMLElement | null;
77+
78+
while (target) {
79+
if (target.classList.contains('twoslash-hover')) {
80+
timeout = setTimeout(clear, 0);
81+
return;
82+
}
83+
84+
target = target.parentElement;
5485
}
5586
}
5687

packages/site-kit/src/lib/markdown/renderer.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createHash, Hash } from 'node:crypto';
33
import fs from 'node:fs';
44
import path from 'node:path';
55
import ts from 'typescript';
6+
import * as marked from 'marked';
67
import { codeToHtml, createCssVariablesTheme } from 'shiki';
78
import { transformerTwoslash } from '@shikijs/twoslash';
89
import { SHIKI_LANGUAGE_MAP, slugify, smart_quotes, transform } from './utils';
@@ -185,11 +186,11 @@ const snippets = await create_snippet_cache();
185186
export async function render_content_markdown(
186187
filename: string,
187188
body: string,
188-
options: { check?: boolean },
189+
options?: { check?: boolean },
189190
twoslashBanner?: TwoslashBanner
190191
) {
191192
const headings: string[] = [];
192-
const { check = true } = options;
193+
const { check = true } = options ?? {};
193194

194195
return await transform(body, {
195196
async walkTokens(token) {
@@ -675,6 +676,66 @@ async function syntax_highlight({
675676
});
676677

677678
html = html.replace(/ {27,}/g, () => redactions.shift()!);
679+
680+
if (check) {
681+
// munge the twoslash output so that it renders sensibly. the order of operations
682+
// here is important — we need to work backwards, to avoid corrupting the offsets
683+
const replacements: Array<{ start: number; end: number; content: string }> = [];
684+
685+
for (const match of html.matchAll(/<div class="twoslash-popup-docs">([^]+?)<\/div>/g)) {
686+
const content = await render_content_markdown('<twoslash>', match[1], { check: false });
687+
688+
replacements.push({
689+
start: match.index,
690+
end: match.index + match[0].length,
691+
content: '<div class="twoslash-popup-docs">' + content + '</div>'
692+
});
693+
}
694+
695+
while (replacements.length > 0) {
696+
const { start, end, content } = replacements.pop()!;
697+
html = html.slice(0, start) + content + html.slice(end);
698+
}
699+
700+
for (const match of html.matchAll(
701+
/<span class="twoslash-popup-docs-tag"><span class="twoslash-popup-docs-tag-name">([^]+?)<\/span><span class="twoslash-popup-docs-tag-value">([^]+?)<\/span><\/span>/g
702+
)) {
703+
const tag = match[1];
704+
let value = match[2];
705+
706+
let content = `<span class="tag">${tag}</span><span class="value">`;
707+
708+
if (tag === '@param' || tag === '@throws') {
709+
const words = value.split(' ');
710+
let param = words.shift()!;
711+
value = words.join(' ');
712+
713+
if (tag === '@throws') {
714+
if (param[0] !== '{' || param[param.length - 1] !== '}') {
715+
throw new Error('TODO robustify @throws handling');
716+
}
717+
718+
param = param.slice(1, -1);
719+
}
720+
721+
content += `<span class="param">${param}</span> `;
722+
}
723+
724+
content += marked.parseInline(value);
725+
content += '</span>';
726+
727+
replacements.push({
728+
start: match.index,
729+
end: match.index + match[0].length,
730+
content: '<div class="tags">' + content + '</div>'
731+
});
732+
}
733+
734+
while (replacements.length > 0) {
735+
const { start, end, content } = replacements.pop()!;
736+
html = html.slice(0, start) + content + html.slice(end);
737+
}
738+
}
678739
} catch (e) {
679740
console.error((e as Error).message);
680741
console.warn(prelude + redacted);

packages/site-kit/src/lib/styles/tokens.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
--sk-text-4: hsl(0, 0%, 45%);
133133
--sk-text-translucent: hsla(0, 0%, 100%, 0.9);
134134
--sk-scrollbar: rgba(255, 255, 255, 0.3);
135-
--sk-shadow: drop-shadow(0px 0px 0 1px var(--sk-back-4));
135+
--sk-shadow: drop-shadow(1px 2px 16px rgb(0 0 0 / 0.5));
136136

137137
--sk-theme-1-variant: hsl(15, 100%, 40%);
138138
--sk-theme-2-variant: hsl(240, 8%, 35%);

0 commit comments

Comments
 (0)