Skip to content

Commit ddfb30b

Browse files
feat: Add term syntax DOCSTOOLS-1268 (#172)
* add term syntax * term tests * misc ref * accessibility ref * accessibility ref tests * refactor & listen scroll inside element * define table/code block as scrollable parents * fit the definition into the document * inherit font style * add linter warning if term definition duplicated * allow multiline term definitions * refactor: split plugin function into parts * chore: rename files * change syntax *[term]: -> [*term]: * change syntax for tests * lint rule - term used without definition * refactor link validation & misc * lint rule - term inside definition not allowed * add lint tokens if lint run * revert extractTitle fn changes * mobile/table style improvement * revert extractTitle fn changes
1 parent 7c0def6 commit ddfb30b

25 files changed

+895
-9
lines changed

src/js/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ import './polyfill';
22
import './tabs';
33
import './code';
44
import './cut';
5+
import './term';

src/js/term/index.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
Selector,
3+
openClass,
4+
openDefinitionClass,
5+
createDefinitionElement,
6+
setDefinitionId,
7+
setDefinitionPosition,
8+
closeDefinition,
9+
} from './utils';
10+
import {getEventTarget, isCustom} from '../utils';
11+
12+
if (typeof document !== 'undefined') {
13+
document.addEventListener('click', (event) => {
14+
const openDefinition = document.getElementsByClassName(
15+
openDefinitionClass,
16+
)[0] as HTMLElement;
17+
const target = getEventTarget(event) as HTMLElement;
18+
19+
const termId = target.getAttribute('id');
20+
const termKey = target.getAttribute('term-key');
21+
let definitionElement = document.getElementById(termKey + '_element');
22+
23+
if (termKey && !definitionElement) {
24+
definitionElement = createDefinitionElement(target);
25+
}
26+
27+
const isSameTerm = openDefinition && termId === openDefinition.getAttribute('term-id');
28+
if (isSameTerm) {
29+
closeDefinition(openDefinition);
30+
return;
31+
}
32+
33+
const isTargetDefinitionContent = target.closest(
34+
[Selector.CONTENT.replace(' ', ''), openClass].join('.'),
35+
);
36+
37+
if (openDefinition && !isTargetDefinitionContent) {
38+
closeDefinition(openDefinition);
39+
}
40+
41+
if (isCustom(event) || !target.matches(Selector.TITLE) || !definitionElement) {
42+
return;
43+
}
44+
45+
setDefinitionId(definitionElement, target);
46+
setDefinitionPosition(definitionElement, target);
47+
48+
definitionElement.classList.toggle(openClass);
49+
});
50+
51+
document.addEventListener('keydown', (event) => {
52+
const openDefinition = document.getElementsByClassName(
53+
openDefinitionClass,
54+
)[0] as HTMLElement;
55+
if (event.key === 'Escape' && openDefinition) {
56+
closeDefinition(openDefinition);
57+
}
58+
});
59+
60+
window.addEventListener('resize', () => {
61+
const openDefinition = document.getElementsByClassName(
62+
openDefinitionClass,
63+
)[0] as HTMLElement;
64+
65+
if (!openDefinition) {
66+
return;
67+
}
68+
69+
const termId = openDefinition.getAttribute('term-id') || '';
70+
const termElement = document.getElementById(termId);
71+
72+
if (!termElement) {
73+
openDefinition.classList.toggle(openClass);
74+
return;
75+
}
76+
77+
setDefinitionPosition(openDefinition, termElement);
78+
});
79+
}

src/js/term/utils.ts

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
export const Selector = {
2+
TITLE: '.yfm .yfm-term_title',
3+
CONTENT: '.yfm .yfm-term_dfn',
4+
};
5+
export const openClass = 'open';
6+
export const openDefinitionClass = Selector.CONTENT.replace(/\./g, '') + ' ' + openClass;
7+
let isListenerNeeded = true;
8+
9+
export function createDefinitionElement(termElement: HTMLElement) {
10+
const termKey = termElement.getAttribute('term-key');
11+
const definitionTemplate = document.getElementById(
12+
`${termKey}_template`,
13+
) as HTMLTemplateElement;
14+
const definitionElement = definitionTemplate?.content.cloneNode(true).firstChild as HTMLElement;
15+
16+
definitionTemplate?.parentElement?.appendChild(definitionElement);
17+
definitionTemplate.remove();
18+
19+
return definitionElement;
20+
}
21+
22+
export function setDefinitionId(definitionElement: HTMLElement, termElement: HTMLElement): void {
23+
const termId = termElement.getAttribute('id') || Math.random().toString(36).substr(2, 8);
24+
definitionElement?.setAttribute('term-id', termId);
25+
}
26+
27+
export function setDefinitionPosition(
28+
definitionElement: HTMLElement,
29+
termElement: HTMLElement,
30+
): void {
31+
const {
32+
x: termX,
33+
y: termY,
34+
right: termRight,
35+
left: termLeft,
36+
width: termWidth,
37+
} = termElement.getBoundingClientRect();
38+
39+
const termParent = termParentElement(termElement);
40+
41+
if (!termParent) {
42+
return;
43+
}
44+
45+
const {right: termParentRight, left: termParentLeft} = termParent.getBoundingClientRect();
46+
47+
if ((termParentRight < termLeft || termParentLeft > termRight) && !isListenerNeeded) {
48+
closeDefinition(definitionElement);
49+
return;
50+
}
51+
52+
if (isListenerNeeded && termParent) {
53+
termParent.addEventListener('scroll', termOnResize);
54+
isListenerNeeded = false;
55+
}
56+
57+
const relativeX = Number(definitionElement.getAttribute('relativeX'));
58+
const relativeY = Number(definitionElement.getAttribute('relativeY'));
59+
60+
if (relativeX === termX && relativeY === termY) {
61+
return;
62+
}
63+
64+
definitionElement.setAttribute('relativeX', String(termX));
65+
definitionElement.setAttribute('relativeY', String(termY));
66+
67+
const offsetTop = 25;
68+
const definitionParent = definitionElement.parentElement;
69+
70+
if (!definitionParent) {
71+
return;
72+
}
73+
74+
const {width: definitionWidth} = definitionElement.getBoundingClientRect();
75+
const {left: definitionParentLeft} = definitionParent.getBoundingClientRect();
76+
77+
// If definition not fit document change base alignment
78+
const definitionRightCoordinate = definitionWidth + Number(getCoords(termElement).left);
79+
const fitDefinitionDocument =
80+
document.body.clientWidth > definitionRightCoordinate ? 0 : definitionWidth - termWidth;
81+
82+
definitionElement.style.top = Number(getCoords(termElement).top + offsetTop) + 'px';
83+
definitionElement.style.left =
84+
Number(
85+
getCoords(termElement).left -
86+
definitionParentLeft +
87+
definitionParent.offsetLeft -
88+
fitDefinitionDocument,
89+
) + 'px';
90+
}
91+
92+
function termOnResize() {
93+
const openDefinition = document.getElementsByClassName(openDefinitionClass)[0] as HTMLElement;
94+
95+
if (!openDefinition) {
96+
return;
97+
}
98+
const termId = openDefinition.getAttribute('term-id') || '';
99+
const termElement = document.getElementById(termId);
100+
101+
if (!termElement) {
102+
return;
103+
}
104+
105+
setDefinitionPosition(openDefinition, termElement);
106+
}
107+
108+
function termParentElement(term: HTMLElement | null) {
109+
if (!term) {
110+
return null;
111+
}
112+
113+
const closestScrollableParent = term.closest('table') || term.closest('code');
114+
115+
return closestScrollableParent || term.parentElement;
116+
}
117+
118+
export function closeDefinition(definition: HTMLElement) {
119+
definition.classList.remove(openClass);
120+
const termId = definition.getAttribute('term-id') || '';
121+
const termParent = termParentElement(document.getElementById(termId));
122+
123+
if (!termParent) {
124+
return;
125+
}
126+
127+
termParent.removeEventListener('scroll', termOnResize);
128+
isListenerNeeded = true;
129+
}
130+
131+
function getCoords(elem: HTMLElement) {
132+
const box = elem.getBoundingClientRect();
133+
134+
const body = document.body;
135+
const docEl = document.documentElement;
136+
137+
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
138+
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
139+
140+
const clientTop = docEl.clientTop || body.clientTop || 0;
141+
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
142+
143+
const top = box.top + scrollTop - clientTop;
144+
const left = box.left + scrollLeft - clientLeft;
145+
146+
return {top: Math.round(top), left: Math.round(left)};
147+
}

src/scss/_term.scss

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
.yfm-term {
2+
&_title {
3+
color: #027bf3;
4+
cursor: pointer;
5+
6+
border-bottom: 1px dotted;
7+
8+
font-size: inherit;
9+
line-height: inherit;
10+
font-style: normal;
11+
12+
&:hover {
13+
color: #004080;
14+
}
15+
}
16+
17+
&_dfn {
18+
position: absolute;
19+
z-index: 1000;
20+
21+
width: fit-content;
22+
max-width: 450px;
23+
24+
@media screen and (max-width: 600px) {
25+
& {
26+
max-width: 80%;
27+
}
28+
}
29+
30+
visibility: hidden;
31+
opacity: 0;
32+
33+
padding: 10px;
34+
35+
background-color: rgb(255, 255, 255);
36+
37+
font-size: inherit;
38+
line-height: inherit;
39+
font-style: normal;
40+
41+
border-radius: 4px;
42+
43+
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
44+
outline: none;
45+
46+
&::before {
47+
content: '';
48+
position: absolute;
49+
z-index: -1;
50+
top: 0;
51+
right: 0;
52+
bottom: 0;
53+
left: 0;
54+
55+
border-radius: inherit;
56+
box-shadow: 0 0 0 1px rgb(229, 229, 229);
57+
}
58+
59+
&.open {
60+
visibility: visible;
61+
62+
animation-name: popup;
63+
animation-duration: 0.1s;
64+
animation-timing-function: ease-out;
65+
animation-fill-mode: forwards;
66+
67+
@keyframes popup {
68+
0% {
69+
opacity: 0;
70+
transform: translateY(10px);
71+
}
72+
100% {
73+
opacity: 1;
74+
transform: translateY(0);
75+
}
76+
}
77+
}
78+
}
79+
}

src/scss/yfm.scss

+1
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
@import 'print';
88
@import 'cut';
99
@import 'file';
10+
@import 'term';

src/transform/index.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {bold} from 'chalk';
22
import attrs from 'markdown-it-attrs';
3+
import Token from 'markdown-it/lib/token';
34

45
import {log, LogLevels} from './log';
56
import makeHighlight from './highlight';
@@ -12,6 +13,7 @@ import anchors from './plugins/anchors';
1213
import code from './plugins/code';
1314
import cut from './plugins/cut';
1415
import deflist from './plugins/deflist';
16+
import term from './plugins/term';
1517
import file from './plugins/file';
1618
import imsize from './plugins/imsize';
1719
import meta from './plugins/meta';
@@ -84,6 +86,7 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType {
8486
yfmTable,
8587
file,
8688
imsize,
89+
term,
8790
],
8891
highlightLangs = {},
8992
...customOptions
@@ -107,13 +110,14 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType {
107110
const md = initMd({html: allowHTML, linkify, highlight, breaks});
108111
// Need for ids of headers
109112
md.use(attrs, {leftDelimiter, rightDelimiter});
113+
110114
plugins.forEach((plugin) => md.use(plugin, pluginOptions));
111115

112116
try {
113117
let title;
114118
let tokens;
115119
let titleTokens;
116-
const env = {};
120+
const env = {} as {[key: string]: Token[] | unknown};
117121

118122
tokens = md.parse(input, env);
119123

@@ -130,8 +134,10 @@ function transform(originInput: string, opts: OptionsType = {}): OutputType {
130134
}
131135

132136
const headings = getHeadings(tokens, needFlatListHeadings);
133-
const html = md.renderer.render(tokens, md.options, env);
134137

138+
// add all term template tokens to the end of the html
139+
const termTokens = (env.termTokens as Token[]) || [];
140+
const html = md.renderer.render([...tokens, ...termTokens], md.options, env);
135141
const assets = md.assets;
136142
const meta = md.meta;
137143

0 commit comments

Comments
 (0)