@griddo/ax 11.12.0 → 11.12.1-rc.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/config/jest/componentsMock.js +7 -5
- package/package.json +2 -2
- package/src/__tests__/components/Browser/Browser.test.tsx +438 -87
- package/src/__tests__/components/Browser/Browser.utils.test.ts +55 -0
- package/src/__tests__/components/ConfigPanel/ConfigPanel.test.tsx +1 -3
- package/src/__tests__/components/Fields/Button/Button.test.tsx +29 -27
- package/src/__tests__/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/ErrorItem.test.tsx +158 -0
- package/src/__tests__/components/HeadingsPreviewModal/ErrorsBanner/ErrorsBanner.test.tsx +90 -0
- package/src/__tests__/components/HeadingsPreviewModal/HeadingsPreviewModal.test.tsx +178 -0
- package/src/__tests__/components/HeadingsPreviewModal/HeadingsPreviewModal.utils.test.tsx +150 -0
- package/src/__tests__/components/KeywordsPreviewModal/KeywordItem/KeywordItem.test.tsx +91 -0
- package/src/__tests__/components/KeywordsPreviewModal/KeywordsPreviewModal.test.tsx +122 -0
- package/src/__tests__/components/KeywordsPreviewModal/KeywordsPreviewModal.utils.test.ts +15 -0
- package/src/__tests__/components/KeywordsPreviewModal/atoms.test.tsx +101 -0
- package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +1 -1
- package/src/__tests__/modules/FramePreview/FramePreview.test.tsx +318 -0
- package/src/__tests__/modules/FramePreview/FramePreview.utils.test.ts +242 -0
- package/src/__tests__/modules/FramePreview/HeadingsOverlay/HeadingsOverlay.test.tsx +185 -0
- package/src/components/Browser/index.tsx +294 -149
- package/src/components/Browser/style.tsx +75 -6
- package/src/components/Browser/utils.tsx +13 -0
- package/src/components/Button/index.tsx +2 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/Field/index.tsx +2 -4
- package/src/components/Fields/AsyncSelect/style.tsx +13 -0
- package/src/components/Fields/FieldGroup/index.tsx +5 -2
- package/src/components/Fields/FieldGroup/style.tsx +32 -7
- package/src/components/Fields/HeadingField/index.tsx +22 -22
- package/src/components/Fields/HiddenField/style.tsx +1 -1
- package/src/components/Fields/NumberField/index.tsx +15 -16
- package/src/components/Fields/NumberField/style.tsx +2 -0
- package/src/components/Fields/ReferenceField/index.tsx +1 -1
- package/src/components/Fields/SEOPreview/index.tsx +36 -0
- package/src/components/Fields/SEOPreview/style.tsx +24 -0
- package/src/components/Fields/Select/index.tsx +5 -1
- package/src/components/Fields/Select/style.tsx +56 -0
- package/src/components/Fields/SummaryButton/index.tsx +18 -9
- package/src/components/Fields/SummaryButton/style.tsx +1 -2
- package/src/components/Fields/TagsField/index.tsx +8 -9
- package/src/components/Fields/UrlField/index.tsx +26 -27
- package/src/components/Fields/index.tsx +2 -0
- package/src/components/FloatingNote/index.tsx +35 -0
- package/src/components/FloatingNote/style.tsx +26 -0
- package/src/components/FloatingPanel/index.tsx +5 -2
- package/src/components/FloatingPanel/style.tsx +2 -1
- package/src/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/index.tsx +85 -0
- package/src/components/HeadingsPreviewModal/ErrorsBanner/ErrorItem/style.tsx +80 -0
- package/src/components/HeadingsPreviewModal/ErrorsBanner/index.tsx +57 -0
- package/src/components/HeadingsPreviewModal/ErrorsBanner/style.tsx +82 -0
- package/src/components/HeadingsPreviewModal/HeadingItem/index.tsx +71 -0
- package/src/components/HeadingsPreviewModal/HeadingItem/style.tsx +77 -0
- package/src/components/HeadingsPreviewModal/index.tsx +148 -0
- package/src/components/HeadingsPreviewModal/style.tsx +82 -0
- package/src/components/HeadingsPreviewModal/utils.tsx +329 -0
- package/src/components/Icon/index.tsx +1 -2
- package/src/components/IconAction/index.tsx +1 -1
- package/src/components/KeywordsPreviewModal/KeywordItem/index.tsx +46 -0
- package/src/components/KeywordsPreviewModal/KeywordItem/style.tsx +64 -0
- package/src/components/KeywordsPreviewModal/atoms.tsx +96 -0
- package/src/components/KeywordsPreviewModal/index.tsx +99 -0
- package/src/components/KeywordsPreviewModal/style.tsx +87 -0
- package/src/components/KeywordsPreviewModal/utils.tsx +22 -0
- package/src/components/MainWrapper/AppBar/index.tsx +8 -1
- package/src/components/MainWrapper/index.tsx +7 -1
- package/src/components/Notification/index.tsx +2 -2
- package/src/components/PageFinder/index.tsx +1 -1
- package/src/components/ResizePanel/index.tsx +4 -3
- package/src/components/ResizePanel/style.tsx +1 -1
- package/src/components/SearchField/style.tsx +2 -2
- package/src/components/SideModal/index.tsx +2 -1
- package/src/components/Tabs/index.tsx +13 -4
- package/src/components/Tabs/style.tsx +7 -8
- package/src/components/Toast/index.tsx +4 -2
- package/src/components/Tooltip/index.tsx +4 -3
- package/src/components/index.tsx +8 -0
- package/src/forms/fields.tsx +70 -68
- package/src/hooks/forms.tsx +22 -1
- package/src/hooks/index.tsx +13 -3
- package/src/hooks/modals.tsx +103 -15
- package/src/hooks/users.tsx +25 -8
- package/src/modules/Forms/atoms.tsx +2 -2
- package/src/modules/FramePreview/HeadingsOverlay/index.tsx +116 -0
- package/src/modules/FramePreview/HeadingsOverlay/style.tsx +34 -0
- package/src/modules/FramePreview/index.tsx +55 -16
- package/src/modules/FramePreview/style.tsx +34 -2
- package/src/modules/FramePreview/utils.tsx +140 -0
- package/src/modules/GlobalEditor/Editor/index.tsx +37 -3
- package/src/modules/GlobalEditor/PageBrowser/index.tsx +19 -2
- package/src/modules/GlobalEditor/Preview/index.tsx +0 -2
- package/src/modules/GlobalEditor/Preview/style.tsx +1 -1
- package/src/modules/GlobalEditor/index.tsx +119 -57
- package/src/modules/PageEditor/Editor/index.tsx +33 -2
- package/src/modules/PageEditor/PageBrowser/index.tsx +20 -2
- package/src/modules/PageEditor/Preview/index.tsx +0 -2
- package/src/modules/PageEditor/Preview/style.tsx +1 -1
- package/src/modules/PageEditor/atoms.tsx +1 -1
- package/src/modules/PageEditor/index.tsx +130 -66
- package/src/modules/PublicPreview/index.tsx +5 -2
- package/src/schemas/pages/GlobalPage.ts +87 -70
- package/src/schemas/pages/Page.ts +87 -70
- package/src/types/index.tsx +12 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Icon } from "@ax/components";
|
|
2
|
+
import type { HeadingNode } from "@ax/types";
|
|
3
|
+
|
|
4
|
+
import * as S from "./style";
|
|
5
|
+
|
|
6
|
+
const HeadingItem = ({
|
|
7
|
+
head,
|
|
8
|
+
index,
|
|
9
|
+
isFiltering,
|
|
10
|
+
selected,
|
|
11
|
+
onHeadingClick,
|
|
12
|
+
counter,
|
|
13
|
+
parentPath,
|
|
14
|
+
}: IHeadingItemProps) => {
|
|
15
|
+
counter.value += 1;
|
|
16
|
+
const headingId = counter.value;
|
|
17
|
+
const uniqueKey = `${parentPath}${head.tag}-${head.level}-${index}-${head.text.slice(0, 20)}`;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<S.HeadItem
|
|
22
|
+
level={isFiltering ? 1 : head.level}
|
|
23
|
+
tag={head.tag}
|
|
24
|
+
tabIndex={0}
|
|
25
|
+
role="button"
|
|
26
|
+
onClick={onHeadingClick(headingId)}
|
|
27
|
+
isSelected={selected === headingId}
|
|
28
|
+
isHidden={head.isHidden}
|
|
29
|
+
data-heading-id={headingId}
|
|
30
|
+
>
|
|
31
|
+
<S.HeadTag>
|
|
32
|
+
<div>{head.tag}</div>
|
|
33
|
+
</S.HeadTag>
|
|
34
|
+
<S.StyledTooltip content={head.isHidden ? "Hidden with CSS" : null}>
|
|
35
|
+
<S.HeadText>
|
|
36
|
+
<div>{head.text}</div>
|
|
37
|
+
{head.isHidden && (
|
|
38
|
+
<S.HiddenIcon>
|
|
39
|
+
<Icon name="hide" size="16" />
|
|
40
|
+
</S.HiddenIcon>
|
|
41
|
+
)}
|
|
42
|
+
</S.HeadText>
|
|
43
|
+
</S.StyledTooltip>
|
|
44
|
+
</S.HeadItem>
|
|
45
|
+
{head.children.map((child, childIndex) => (
|
|
46
|
+
<HeadingItem
|
|
47
|
+
key={`${uniqueKey}-child-${childIndex}`}
|
|
48
|
+
head={child}
|
|
49
|
+
index={childIndex}
|
|
50
|
+
isFiltering={isFiltering}
|
|
51
|
+
selected={selected}
|
|
52
|
+
onHeadingClick={onHeadingClick}
|
|
53
|
+
counter={counter}
|
|
54
|
+
parentPath={`${uniqueKey}-`}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface IHeadingItemProps {
|
|
62
|
+
head: HeadingNode;
|
|
63
|
+
index: number;
|
|
64
|
+
isFiltering: boolean;
|
|
65
|
+
selected: number | null;
|
|
66
|
+
onHeadingClick: (id: number) => () => void;
|
|
67
|
+
counter: { value: number };
|
|
68
|
+
parentPath: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default HeadingItem;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
|
|
3
|
+
import Tooltip from "../../Tooltip";
|
|
4
|
+
|
|
5
|
+
import { getHeadColor } from "../utils";
|
|
6
|
+
|
|
7
|
+
const HeadTag = styled.div`
|
|
8
|
+
display: flex;
|
|
9
|
+
text-transform: uppercase;
|
|
10
|
+
flex-shrink: 0;
|
|
11
|
+
padding: ${(p) => `0 ${p.theme.spacing.xs}`};
|
|
12
|
+
align-items: center;
|
|
13
|
+
div {
|
|
14
|
+
transform: rotate(-90deg);
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const HeadText = styled.div`
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
padding: ${(p) => `${p.theme.spacing.xs} ${p.theme.spacing.s}`};
|
|
22
|
+
width: 100%;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const HeadItem = styled.div<{ level: number; tag: string; isSelected: boolean; isHidden: boolean }>`
|
|
26
|
+
position: relative;
|
|
27
|
+
${(p) => p.theme.textStyle.uiS};
|
|
28
|
+
color: ${(p) => p.theme.colors.textHighEmphasis};
|
|
29
|
+
background-color: ${(p) => p.theme.colors.uiBackground02};
|
|
30
|
+
display: flex;
|
|
31
|
+
margin-left: ${(p) => `${(p.level - 1) * 16}px`};
|
|
32
|
+
margin-bottom: ${(p) => p.theme.spacing.xs};
|
|
33
|
+
min-height: 40px;
|
|
34
|
+
border: ${(p) => `1px solid ${getHeadColor(p.tag)}`};
|
|
35
|
+
border-radius: ${(p) => p.theme.radii.s};
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
box-shadow: ${(p) => p.theme.shadow.shadowS};
|
|
38
|
+
outline: ${(p) => (p.isSelected ? `1px solid ${p.theme.colors.interactive01}` : "none")};
|
|
39
|
+
|
|
40
|
+
&:before {
|
|
41
|
+
content: "";
|
|
42
|
+
border-radius: ${(p) => p.theme.radii.s};
|
|
43
|
+
position: absolute;
|
|
44
|
+
top: 0;
|
|
45
|
+
left: 0;
|
|
46
|
+
width: 100%;
|
|
47
|
+
height: 100%;
|
|
48
|
+
opacity: 0;
|
|
49
|
+
transition: opacity 0.1s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&:hover:before {
|
|
53
|
+
background-color: ${(p) => p.theme.colors.overlayHoverPrimary};
|
|
54
|
+
opacity: 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
${HeadTag} {
|
|
58
|
+
background-color: ${(p) => getHeadColor(p.tag)};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
${HeadText} {
|
|
62
|
+
color: ${(p) => (p.isHidden ? p.theme.colors.interactiveDisabled : p.theme.colors.textHighEmphasis)};
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const HiddenIcon = styled.div`
|
|
67
|
+
margin-left: auto;
|
|
68
|
+
svg path {
|
|
69
|
+
fill: ${(p) => p.theme.colors.interactiveDisabled};
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const StyledTooltip = styled(Tooltip)`
|
|
74
|
+
width: 100%;
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
export { HeadItem, HeadTag, HeadText, HiddenIcon, StyledTooltip };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { EmptyState, FloatingPanel } from "@ax/components";
|
|
4
|
+
import type { HeadingFilter, HeadingNode } from "@ax/types";
|
|
5
|
+
|
|
6
|
+
import ErrorsBanner from "./ErrorsBanner";
|
|
7
|
+
import HeadingItem from "./HeadingItem";
|
|
8
|
+
import { analyzeHeadings, extractUniqueHeadingTypes, filterHeadings, parseHeadingsTree } from "./utils";
|
|
9
|
+
|
|
10
|
+
import * as S from "./style";
|
|
11
|
+
|
|
12
|
+
const HeadingsPreviewModal = (props: IHeadingsPreviewProps) => {
|
|
13
|
+
const { isOpen, browserRef, headingsFilter, setHeadingsFilter, toggleModal } = props;
|
|
14
|
+
|
|
15
|
+
const isFiltering = headingsFilter !== "all";
|
|
16
|
+
const counter = { value: 0 };
|
|
17
|
+
|
|
18
|
+
const [headings, setHeadings] = useState<HeadingNode[]>([]);
|
|
19
|
+
const [selected, setSelected] = useState<number | null>(null);
|
|
20
|
+
const [isErrorsBannerOpen, setIsErrorsBannerOpen] = useState(false);
|
|
21
|
+
const [errorsResetKey, setErrorsResetKey] = useState(0);
|
|
22
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const filters = useMemo<HeadingFilter[]>(() => ["all", ...extractUniqueHeadingTypes(headings)], [headings]);
|
|
25
|
+
const filteredHeadings = useMemo(() => filterHeadings(headings, headingsFilter), [headings, headingsFilter]);
|
|
26
|
+
const errors = useMemo(() => analyzeHeadings(filteredHeadings, isFiltering), [filteredHeadings, isFiltering]);
|
|
27
|
+
|
|
28
|
+
const getSEOHeadings = useCallback(() => {
|
|
29
|
+
return browserRef.current ? parseHeadingsTree(browserRef.current) : [];
|
|
30
|
+
}, [browserRef]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (isOpen) {
|
|
34
|
+
const headings = getSEOHeadings();
|
|
35
|
+
setHeadings(headings);
|
|
36
|
+
} else {
|
|
37
|
+
setHeadingsFilter("all");
|
|
38
|
+
}
|
|
39
|
+
}, [isOpen, getSEOHeadings, setHeadingsFilter]);
|
|
40
|
+
|
|
41
|
+
const closeErrorsBanner = () => {
|
|
42
|
+
setIsErrorsBannerOpen(false);
|
|
43
|
+
setErrorsResetKey((k) => k + 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleFilterClick = (value: HeadingFilter) => () => {
|
|
47
|
+
setSelected(null);
|
|
48
|
+
setHeadingsFilter(value);
|
|
49
|
+
closeErrorsBanner();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const scrollToHeadingInIframe = (id: number) => {
|
|
53
|
+
const iframe = browserRef.current?.querySelector("iframe");
|
|
54
|
+
const iframeDocument = iframe?.contentDocument || iframe?.contentWindow?.document;
|
|
55
|
+
const heading = iframeDocument?.querySelector(`[data-griddoid="heading-${id}"]`);
|
|
56
|
+
heading?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleHeadingClick = (id: number) => () => {
|
|
60
|
+
if (id !== selected) {
|
|
61
|
+
setSelected(id);
|
|
62
|
+
scrollToHeadingInIframe(id);
|
|
63
|
+
} else {
|
|
64
|
+
setSelected(null);
|
|
65
|
+
}
|
|
66
|
+
closeErrorsBanner();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleHeadingNavigate = (id: number) => () => {
|
|
70
|
+
setSelected(id);
|
|
71
|
+
scrollToHeadingInIframe(id);
|
|
72
|
+
|
|
73
|
+
const listItem = listRef.current?.querySelector(`[data-heading-id="${id}"]`);
|
|
74
|
+
listItem?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<S.Wrapper>
|
|
79
|
+
<FloatingPanel title="Headings" toggleModal={toggleModal} closeOnOutsideClick={false} isOpen={isOpen} width={358}>
|
|
80
|
+
{isOpen &&
|
|
81
|
+
(!filteredHeadings.length ? (
|
|
82
|
+
<S.EmptyWrapper>
|
|
83
|
+
<EmptyState
|
|
84
|
+
message={
|
|
85
|
+
<>
|
|
86
|
+
There are no headings available to display.
|
|
87
|
+
<br />
|
|
88
|
+
Feel free to add some to get started!
|
|
89
|
+
</>
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
</S.EmptyWrapper>
|
|
93
|
+
) : (
|
|
94
|
+
<S.HeadingsWrapper>
|
|
95
|
+
{errors.length > 0 && (
|
|
96
|
+
<ErrorsBanner
|
|
97
|
+
errors={errors}
|
|
98
|
+
onSelectHeading={handleHeadingNavigate}
|
|
99
|
+
isOpen={isErrorsBannerOpen}
|
|
100
|
+
setIsOpen={setIsErrorsBannerOpen}
|
|
101
|
+
resetKey={errorsResetKey}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
<S.FiltersWrapper>
|
|
105
|
+
<S.FilterText>Show headings:</S.FilterText>
|
|
106
|
+
{filters.map((type) => (
|
|
107
|
+
<S.FilterItem
|
|
108
|
+
key={type}
|
|
109
|
+
type="button"
|
|
110
|
+
tabIndex={0}
|
|
111
|
+
isSelected={headingsFilter === type}
|
|
112
|
+
onClick={handleFilterClick(type)}
|
|
113
|
+
tag={type}
|
|
114
|
+
>
|
|
115
|
+
{type}
|
|
116
|
+
</S.FilterItem>
|
|
117
|
+
))}
|
|
118
|
+
</S.FiltersWrapper>
|
|
119
|
+
<S.HeadingsListWrapper ref={listRef}>
|
|
120
|
+
{filteredHeadings.map((head, index) => (
|
|
121
|
+
<HeadingItem
|
|
122
|
+
key={`${head.tag}-${head.level}-${index}-${head.text.slice(0, 20)}`}
|
|
123
|
+
head={head}
|
|
124
|
+
index={index}
|
|
125
|
+
isFiltering={isFiltering}
|
|
126
|
+
selected={selected}
|
|
127
|
+
onHeadingClick={handleHeadingClick}
|
|
128
|
+
counter={counter}
|
|
129
|
+
parentPath=""
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</S.HeadingsListWrapper>
|
|
133
|
+
</S.HeadingsWrapper>
|
|
134
|
+
))}
|
|
135
|
+
</FloatingPanel>
|
|
136
|
+
</S.Wrapper>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
interface IHeadingsPreviewProps {
|
|
141
|
+
isOpen: boolean;
|
|
142
|
+
browserRef: React.RefObject<HTMLDivElement>;
|
|
143
|
+
toggleModal: () => void;
|
|
144
|
+
headingsFilter: HeadingFilter;
|
|
145
|
+
setHeadingsFilter: (value: HeadingFilter) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default HeadingsPreviewModal;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
|
|
3
|
+
import { getHeadColor } from "./utils";
|
|
4
|
+
|
|
5
|
+
const Wrapper = styled.div``;
|
|
6
|
+
|
|
7
|
+
const HeadingsWrapper = styled.div`
|
|
8
|
+
padding: ${(p) => `${p.theme.spacing.m} ${p.theme.spacing.m} 80px ${p.theme.spacing.s}`};
|
|
9
|
+
overflow-y: auto;
|
|
10
|
+
height: 100%;
|
|
11
|
+
width: 100%;
|
|
12
|
+
position: relative;
|
|
13
|
+
|
|
14
|
+
::-webkit-scrollbar {
|
|
15
|
+
-webkit-appearance: none;
|
|
16
|
+
width: 4px;
|
|
17
|
+
height: 100%;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
::-webkit-scrollbar-thumb {
|
|
21
|
+
border-radius: 4px;
|
|
22
|
+
background-color: ${(p) => p.theme.colors.iconNonActive};
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const FiltersWrapper = styled.div`
|
|
27
|
+
display: flex;
|
|
28
|
+
background-color: ${(p) => p.theme.colors.uiBackground03};
|
|
29
|
+
border-radius: ${(p) => p.theme.radii.s};
|
|
30
|
+
padding: ${(p) => p.theme.spacing.xs};
|
|
31
|
+
margin-bottom: ${(p) => p.theme.spacing.xs};
|
|
32
|
+
gap: ${(p) => p.theme.spacing.xs};
|
|
33
|
+
overflow-x: auto;
|
|
34
|
+
width: 100%;
|
|
35
|
+
|
|
36
|
+
::-webkit-scrollbar {
|
|
37
|
+
-webkit-appearance: none;
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: 4px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
::-webkit-scrollbar-thumb {
|
|
43
|
+
border-radius: 4px;
|
|
44
|
+
background-color: ${(p) => p.theme.color.iconNonActive};
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const FilterText = styled.div`
|
|
49
|
+
${(p) => p.theme.textStyle.uiS};
|
|
50
|
+
color: ${(p) => p.theme.colors.textHighEmphasis};
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const FilterItem = styled.button<{ isSelected: boolean; tag: string }>`
|
|
55
|
+
${(p) => p.theme.textStyle.uiXS};
|
|
56
|
+
color: ${(p) => (p.isSelected ? p.theme.colors.textHighEmphasis : p.theme.colors.textMediumEmphasis)};
|
|
57
|
+
text-transform: uppercase;
|
|
58
|
+
border-radius: ${(p) => p.theme.radii.xs};
|
|
59
|
+
background-color: ${(p) => getHeadColor(p.tag, !p.isSelected ? 0.5 : undefined)};
|
|
60
|
+
padding: ${(p) => `0 ${p.theme.spacing.xs}`};
|
|
61
|
+
outline: ${(p) => (p.isSelected ? `1px solid ${p.theme.colors.interactive01}` : "none")};
|
|
62
|
+
font-weight: ${(p) => (p.isSelected ? "700" : "500")};
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const HeadingsListWrapper = styled.div``;
|
|
67
|
+
|
|
68
|
+
const EmptyWrapper = styled.div`
|
|
69
|
+
height: 100%;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
Wrapper,
|
|
76
|
+
HeadingsWrapper,
|
|
77
|
+
FiltersWrapper,
|
|
78
|
+
FilterText,
|
|
79
|
+
FilterItem,
|
|
80
|
+
HeadingsListWrapper,
|
|
81
|
+
EmptyWrapper,
|
|
82
|
+
};
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { HeadingFilter, HeadingLevel, HeadingNode } from "@ax/types";
|
|
2
|
+
|
|
3
|
+
const MAX_HEADING_LENGTH = 70;
|
|
4
|
+
|
|
5
|
+
const headColorsRGB: Record<string, { r: number; g: number; b: number }> = {
|
|
6
|
+
h1: { r: 255, g: 240, b: 109 },
|
|
7
|
+
h2: { r: 255, g: 184, b: 248 },
|
|
8
|
+
h3: { r: 115, g: 248, b: 200 },
|
|
9
|
+
h4: { r: 155, g: 237, b: 255 },
|
|
10
|
+
h5: { r: 198, g: 193, b: 255 },
|
|
11
|
+
h6: { r: 255, g: 206, b: 149 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getHeadColor = (tag: string, opacity?: number): string => {
|
|
15
|
+
const color = headColorsRGB[tag];
|
|
16
|
+
if (!color) return "rgb(255, 255, 255)";
|
|
17
|
+
if (opacity !== undefined) {
|
|
18
|
+
return `rgba(${color.r}, ${color.g}, ${color.b}, ${opacity})`;
|
|
19
|
+
}
|
|
20
|
+
return `rgb(${color.r}, ${color.g}, ${color.b})`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const flattenHeadings = (headings: HeadingNode[], counter = { value: 0 }): IFlatHeading[] => {
|
|
24
|
+
const result: IFlatHeading[] = [];
|
|
25
|
+
|
|
26
|
+
for (const heading of headings) {
|
|
27
|
+
counter.value += 1;
|
|
28
|
+
result.push({
|
|
29
|
+
id: counter.value,
|
|
30
|
+
level: heading.level,
|
|
31
|
+
tag: heading.tag,
|
|
32
|
+
text: heading.text,
|
|
33
|
+
isHidden: heading.isHidden,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (heading.children.length > 0) {
|
|
37
|
+
result.push(...flattenHeadings(heading.children, counter));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const analyzeHeadings = (headings: HeadingNode[], isFiltering = false): IHeadingError[] => {
|
|
45
|
+
const errors: IHeadingError[] = [];
|
|
46
|
+
const flatHeadings = flattenHeadings(headings);
|
|
47
|
+
|
|
48
|
+
if (flatHeadings.length === 0) {
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 1. Check for missing H1
|
|
53
|
+
const hasH1 = flatHeadings.some((h) => h.tag === "h1");
|
|
54
|
+
if (!isFiltering && !hasH1) {
|
|
55
|
+
errors.push({
|
|
56
|
+
message: "No H1 in this page",
|
|
57
|
+
description: (
|
|
58
|
+
<>
|
|
59
|
+
Add a H1 tag to improve page
|
|
60
|
+
<br />
|
|
61
|
+
structure and SEO visibility.
|
|
62
|
+
</>
|
|
63
|
+
),
|
|
64
|
+
headingIds: [],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Check for incorrect nesting (skipped levels)
|
|
69
|
+
const nestingErrorIds: number[] = [];
|
|
70
|
+
for (let i = 1; i < flatHeadings.length; i++) {
|
|
71
|
+
const prevLevel = flatHeadings[i - 1].level;
|
|
72
|
+
const currLevel = flatHeadings[i].level;
|
|
73
|
+
|
|
74
|
+
if (currLevel > prevLevel && currLevel - prevLevel > 1) {
|
|
75
|
+
nestingErrorIds.push(flatHeadings[i].id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (nestingErrorIds.length > 0) {
|
|
79
|
+
errors.push({
|
|
80
|
+
message: "Check heading structure for SEO compliance",
|
|
81
|
+
description: (
|
|
82
|
+
<>
|
|
83
|
+
Ensure each heading is properly nested
|
|
84
|
+
<br />
|
|
85
|
+
and follows SEO best practices.
|
|
86
|
+
</>
|
|
87
|
+
),
|
|
88
|
+
headingIds: nestingErrorIds,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Check for duplicate texts
|
|
93
|
+
const textMap = new Map<string, number[]>();
|
|
94
|
+
const visibleHeadings = flatHeadings.filter((h) => !h.isHidden);
|
|
95
|
+
for (const heading of visibleHeadings) {
|
|
96
|
+
const normalizedText = heading.text.toLowerCase().trim();
|
|
97
|
+
if (normalizedText) {
|
|
98
|
+
const ids = textMap.get(normalizedText) || [];
|
|
99
|
+
ids.push(heading.id);
|
|
100
|
+
textMap.set(normalizedText, ids);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const duplicateIds: number[] = [];
|
|
104
|
+
for (const ids of textMap.values()) {
|
|
105
|
+
if (ids.length > 1) {
|
|
106
|
+
duplicateIds.push(...ids);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (duplicateIds.length > 0) {
|
|
110
|
+
errors.push({
|
|
111
|
+
message: "Avoid header repetitions for SEO",
|
|
112
|
+
description: (
|
|
113
|
+
<>
|
|
114
|
+
Diversify your headings to enhance SEO.
|
|
115
|
+
<br />
|
|
116
|
+
Avoid using the same heading multiple times.
|
|
117
|
+
</>
|
|
118
|
+
),
|
|
119
|
+
headingIds: duplicateIds,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Check for excessive length
|
|
124
|
+
const excessiveLengthIds: number[] = [];
|
|
125
|
+
for (const heading of flatHeadings) {
|
|
126
|
+
if (heading.text.length > MAX_HEADING_LENGTH) {
|
|
127
|
+
excessiveLengthIds.push(heading.id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (excessiveLengthIds.length > 0) {
|
|
131
|
+
errors.push({
|
|
132
|
+
message: "Some headings are too long",
|
|
133
|
+
description: (
|
|
134
|
+
<>
|
|
135
|
+
Optimize SEO by shortening
|
|
136
|
+
<br />
|
|
137
|
+
headings to ensure clarity.
|
|
138
|
+
</>
|
|
139
|
+
),
|
|
140
|
+
headingIds: excessiveLengthIds,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return errors;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const parseHeadingsTree = (html: HTMLDivElement): HeadingNode[] => {
|
|
148
|
+
const frameObject = html.querySelector<HTMLIFrameElement>(".frame-content");
|
|
149
|
+
const frameContent = frameObject?.contentWindow?.document.getElementById("___griddo") as HTMLElement;
|
|
150
|
+
|
|
151
|
+
if (!frameContent) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const iframeWindow = frameObject?.contentWindow;
|
|
156
|
+
const headings = Array.from(frameContent.querySelectorAll("h1, h2, h3, h4, h5, h6")) as HTMLHeadingElement[];
|
|
157
|
+
|
|
158
|
+
const root: HeadingNode[] = [];
|
|
159
|
+
const stack: HeadingNode[] = [];
|
|
160
|
+
|
|
161
|
+
const isElementHidden = (el: HTMLElement): boolean => {
|
|
162
|
+
if (!iframeWindow) return false;
|
|
163
|
+
|
|
164
|
+
const style = iframeWindow.getComputedStyle(el);
|
|
165
|
+
|
|
166
|
+
// 1. Check CSS properties directly on element
|
|
167
|
+
if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Check for hidden attribute
|
|
172
|
+
if (el.hidden) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. Check dimensions (width or height is 0)
|
|
177
|
+
const rect = el.getBoundingClientRect();
|
|
178
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. Check if completely out of viewport
|
|
183
|
+
const frameRect = frameObject?.getBoundingClientRect();
|
|
184
|
+
if (frameRect) {
|
|
185
|
+
if (
|
|
186
|
+
rect.bottom < frameRect.top ||
|
|
187
|
+
rect.top > frameRect.bottom ||
|
|
188
|
+
rect.right < frameRect.left ||
|
|
189
|
+
rect.left > frameRect.right
|
|
190
|
+
) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. Check if element is covered by another element (elementFromPoint)
|
|
196
|
+
const centerX = rect.left + rect.width / 2;
|
|
197
|
+
const centerY = rect.top + rect.height / 2;
|
|
198
|
+
if (frameObject && centerX >= 0 && centerY >= 0 && centerX <= window.innerWidth && centerY <= window.innerHeight) {
|
|
199
|
+
const topElement = document.elementFromPoint(centerX, centerY);
|
|
200
|
+
if (topElement && topElement !== el && !el.contains(topElement)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const isAncestorHidden = (el: HTMLElement): boolean => {
|
|
209
|
+
let node: HTMLElement | null = el.parentElement;
|
|
210
|
+
while (node && node !== frameContent) {
|
|
211
|
+
if (iframeWindow) {
|
|
212
|
+
const style = iframeWindow.getComputedStyle(node);
|
|
213
|
+
|
|
214
|
+
// Check for display:none or visibility:hidden in ancestor
|
|
215
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check for opacity:0 with animation check
|
|
220
|
+
if (parseFloat(style.opacity) === 0) {
|
|
221
|
+
const hasAnimation = style.animationName !== "none";
|
|
222
|
+
const transitionProps = style.transitionProperty.split(",").map((p) => p.trim());
|
|
223
|
+
const hasOpacityTransition = transitionProps.includes("opacity") || transitionProps.includes("all");
|
|
224
|
+
if (!hasAnimation && !hasOpacityTransition) return true;
|
|
225
|
+
|
|
226
|
+
// If opacity is 0 and element is outside document flow or can't receive events,
|
|
227
|
+
// it's effectively hidden regardless of transition state
|
|
228
|
+
if (style.pointerEvents === "none" || (node as HTMLElement).offsetParent === null) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check if ancestor is in collapsed navigation
|
|
234
|
+
const navSelector = "nav, header, [role='navigation'], .nav, .menu, .navbar, .sidebar";
|
|
235
|
+
if (node.matches(navSelector)) {
|
|
236
|
+
const ancestorRect = node.getBoundingClientRect();
|
|
237
|
+
const nodeHeight = parseFloat(style.height);
|
|
238
|
+
const nodeMaxHeight = parseFloat(style.maxHeight);
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
(nodeHeight === 0 || nodeMaxHeight === 0 || style.overflow === "hidden") &&
|
|
242
|
+
(ancestorRect.height === 0 || ancestorRect.height < 10)
|
|
243
|
+
) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
node = node.parentElement;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
for (const heading of headings) {
|
|
254
|
+
const level = Number(heading.tagName[1]) as HeadingLevel;
|
|
255
|
+
const isFixedPosition = iframeWindow?.getComputedStyle(heading).position === "fixed";
|
|
256
|
+
const isHidden =
|
|
257
|
+
isElementHidden(heading) || (heading.offsetParent === null && !isFixedPosition) || isAncestorHidden(heading);
|
|
258
|
+
|
|
259
|
+
const node: HeadingNode = {
|
|
260
|
+
level,
|
|
261
|
+
tag: heading.tagName.toLowerCase() as HeadingNode["tag"],
|
|
262
|
+
text: heading.textContent?.trim() ?? "",
|
|
263
|
+
isHidden: !!isHidden,
|
|
264
|
+
children: [],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Subir en la jerarquía si el nivel actual es menor o igual
|
|
268
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
|
269
|
+
stack.pop();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (stack.length === 0) {
|
|
273
|
+
root.push(node);
|
|
274
|
+
} else {
|
|
275
|
+
stack[stack.length - 1].children.push(node);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
stack.push(node);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return root;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const extractUniqueHeadingTypes = (headings: HeadingNode[]): HeadingFilter[] => {
|
|
285
|
+
const types = new Set<HeadingFilter>();
|
|
286
|
+
|
|
287
|
+
const traverse = (nodes: HeadingNode[]) => {
|
|
288
|
+
for (const node of nodes) {
|
|
289
|
+
types.add(node.tag);
|
|
290
|
+
if (node.children && node.children.length > 0) {
|
|
291
|
+
traverse(node.children);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
traverse(headings);
|
|
297
|
+
return Array.from(types).sort();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const filterHeadings = (headings: HeadingNode[], selectedType: string): HeadingNode[] => {
|
|
301
|
+
if (selectedType === "all") return headings;
|
|
302
|
+
|
|
303
|
+
return flattenHeadings(headings)
|
|
304
|
+
.filter((h) => h.tag === selectedType)
|
|
305
|
+
.map((h) => ({
|
|
306
|
+
level: h.level,
|
|
307
|
+
tag: h.tag,
|
|
308
|
+
text: h.text,
|
|
309
|
+
isHidden: h.isHidden,
|
|
310
|
+
children: [],
|
|
311
|
+
}));
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
interface IHeadingError {
|
|
315
|
+
message: string;
|
|
316
|
+
description: React.ReactNode;
|
|
317
|
+
headingIds: number[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface IFlatHeading {
|
|
321
|
+
id: number;
|
|
322
|
+
level: HeadingLevel;
|
|
323
|
+
tag: HeadingNode["tag"];
|
|
324
|
+
text: string;
|
|
325
|
+
isHidden: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { parseHeadingsTree, extractUniqueHeadingTypes, filterHeadings, analyzeHeadings, getHeadColor };
|
|
329
|
+
export type { IHeadingError };
|