import texts from "../../i18n"; import type { MyTagMode } from "../request"; import { addMyTag, deleteMyTag, requestPage, updateGalleryFavorite, } from "../request"; import type { GalleryCategoryAppearance, GalleryFavoriteOption, GalleryFavoriteInfo, GalleryHistoryInfo, GalleryRatingInfo, GalleryTagData, } from "../types"; import { galleryTagNameFromUrl } from "../url"; import { anyDomNode, createAnchor, createManagedElement, DomNode, retainedOriginalPageNode, type ManagedDomElements, type ManagedDomNode, } from "./core"; import { extractMyTagsPageData, type GalleryPreviewData, type GalleryPreviewDom, } from "./gallery"; import { domClass } from "./domClass"; import { externalDom } from "./external"; const GALLERY_CATEGORY_FLAGS = { ct1: 1, ct2: 2, ct3: 4, ct4: 8, ct5: 16, ct6: 32, ct7: 64, ct8: 128, ct9: 256, cta: 512, } as const; /** Extracts Gallery display fields persisted with local reading history. */ export function extractGalleryHistoryInfo(): GalleryHistoryInfo { const page = DomNode.from(document); const source = page.use(domClass.gallery.info); const category = source.category.one(); const historyCategory = page.one( domClass.gallery.info.category, retainedOriginalPageNode, ) ?? category; const categoryClass = ( historyCategory?.one( domClass.gallery.info.category.appearance, anyDomNode, )?.attribute("class") ?? historyCategory?.attribute("class") ?? "" ).split(/\s+/).find((className) => /^ct[1-9a]$/.test(className)); const readDetailRows = (details: DomNode | null) => details ?.all(domClass.gallery.info.details.rows, anyDomNode) .map((detailRow) => detailRow.all( domClass.gallery.info.details.rows.cells, anyDomNode, ) .slice(1) .map((detailCell) => detailCell.text()) .filter(Boolean) .join(" ")) ?? []; const retainedDetails = page.one( domClass.gallery.info.details, retainedOriginalPageNode, ); const retainedRows = readDetailRows(retainedDetails); const rows = retainedRows.length > 0 ? retainedRows : source.details.rows.all().map((detailRow) => detailRow.all(domClass.gallery.info.details.rows.cells) .slice(1) .map((detailCell) => detailCell.text()) .filter(Boolean) .join(" ")); // History needs E-H's stable timestamp; translated visible copies may contain relative text. const postedAt = page .one(domClass.gallery.info.details, anyDomNode) ?.all(domClass.gallery.info.details.rows.cells, anyDomNode) .map((cell) => parseGalleryPostedAt(cell.text())) .find((value) => value !== undefined); const ratingMatch = ( page.all(domClass.common.scripts).map((pageScript) => pageScript.text()) .find((script) => script.includes("display_rating")) ?? "" ).match(/\bdisplay_rating\s*=\s*(-?\d+(?:\.\d+)?)/); const rating = Number(ratingMatch?.[1]); const cover = source.cover.one(); let coverUrl = source.cover.image.one()?.attribute("src") ?? ""; if (!coverUrl && cover) { for (const node of [cover, ...cover.all(domClass.common.descendants)]) { const match = node.computedStyle().backgroundImage.match(/url\(["']?(.+?)["']?\)/); if (match?.[1]) { coverUrl = match[1]; break; } } } return { category: historyCategory?.text() || undefined, categoryClass, coverUrl: coverUrl || undefined, language: rows[3] || undefined, postedAt, rating: ratingMatch && Number.isFinite(rating) ? rating : undefined, title: page.one(domClass.gallery.info.titleMain, retainedOriginalPageNode)?.text() || source.titleMain.one()?.text() || undefined, titleSub: page.one(domClass.gallery.info.titleSub, retainedOriginalPageNode)?.text() || source.titleSub.one()?.text() || undefined, uploader: page.one(domClass.gallery.info.uploader, retainedOriginalPageNode)?.text() || source.uploader.one()?.text() || undefined, }; } function parseGalleryPostedAt(value: string | undefined): number | undefined { const match = value?.match( /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/, ); if (!match) { return undefined; } const [, year, month, day, hour, minute, second = "0"] = match; return Date.UTC( Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), ); } /** Manages E-H's gallery header for GalleryInfoPanel. */ export function manageGalleryInfo( preview: GalleryPreviewData | null, ) { const mount = createAnchor("gallery-info"); if (!mount) { return null; } const page = DomNode.from(document); const gallery = page.use(domClass.gallery); const source = gallery.info; const original = source.original.one(); const host = original?.parent() ?? source.hostFallback.one()?.parent() ?? null; if (!original || !host) { return null; } const readMeta = () => { // E-H keeps these rows in a fixed order; their labels may already be translated. const rows = source.details.rows .all() .map((detailRow) => { const cells = detailRow.all(domClass.gallery.info.details.rows.cells); return { label: cells[0]?.text() ?? "", value: cells.slice(1).map((cell) => cell.text()).filter(Boolean).join(" "), }; }); return { favorited: rows[6]?.value ? [rows[6].label, rows[6].value].filter(Boolean).join(" ") : undefined, fileSize: rows[4]?.value, language: rows[3]?.value, parent: rows[1]?.value, posted: rows[0]?.value, }; }; const readCategory = ( node: DomNode | null, ): GalleryCategoryAppearance => { const style = node?.computedStyle(); return { "background-color": style?.backgroundColor ?? "", "background-image": style?.backgroundImage ?? "", "border-color": style?.borderColor ?? "", color: style?.color ?? "", }; }; const readCategoryUrl = ( node: DomNode | null, ): string | null => { const categoryClass = node ?.attribute("class") ?.split(/\s+/) .find((className): className is keyof typeof GALLERY_CATEGORY_FLAGS => className in GALLERY_CATEGORY_FLAGS); if (!categoryClass) { return null; } const url = new URL("/", window.location.href); url.searchParams.set( "f_cats", String(1023 - GALLERY_CATEGORY_FLAGS[categoryClass]), ); return url.href; }; const readCoverUrl = ( cover: DomNode | null, source: DomNode | null, ) => { const direct = source?.attribute("src") ?? ""; if (direct) { return direct; } for (const node of cover ? [cover, ...cover.all(domClass.common.descendants)] : []) { const match = node .computedStyle() .backgroundImage.match(/url\(["']?(.+?)["']?\)/); if (match?.[1]) { return match[1]; } } return ""; }; const readFavorite = ( element: DomNode | null, scripts: string[], ): GalleryFavoriteInfo => { const displayed = element?.one(domClass.gallery.info.favorite.link)?.text() || element?.one(domClass.gallery.info.favorite.titled)?.attribute("title")?.trim() || ""; const slot = displayed.match(/(?:^|\D)([0-9])(?:\D|$)/)?.[1]; const favorited = slot !== undefined || /^favorited$/i.test(displayed); const script = scripts.find( (item) => item.includes("popbase") && item.includes("addfav"), ) ?? ""; const match = script.match( /popbase\s*=\s*base_url\s*\+\s*"gallerypopups\.php\?gid=(\d+)&t=([^"]+)&act="/, ); return { actionUrl: match ? `/gallerypopups.php?gid=${match[1]}&t=${match[2]}&act=addfav` : "", color: slot === undefined ? null : `var(--color-site-favorite-${slot})`, favorited, label: favorited ? displayed : texts.gallery.notFavorited, }; }; const readRating = ( count: DomNode | null, image: DomNode | null, labelNode: DomNode | null, scripts: string[], ): GalleryRatingInfo | null => { const label = labelNode?.text() ?? ""; const match = ( scripts.find((item) => item.includes("display_rating")) ?? "" ).match(/\bdisplay_rating\s*=\s*(-?\d+(?:\.\d+)?)/); const scriptValue = Number(match?.[1]); const value = match && Number.isFinite(scriptValue) ? scriptValue : null; return label && value !== null ? { count: count?.text() ?? "", label, rated: image?.matches(domClass.gallery.info.rating.rated) ?? false, value, } : null; }; const readActions = () => gallery.actions.items .all() .filter((node) => { const href = node.attribute("href")?.trim() ?? ""; return ( node.hasAttribute("onclick") || Boolean(href && href !== "#" && !/^javascript:/i.test(href)) ); }) .slice(0, 6); const readTag = (tag: DomNode) => { const label = tag.text() || tag.attribute(externalDom.tagLabelAttribute)?.trim() || tag.attribute("title")?.trim() || ""; const href = tag.attribute("href") ?? ""; const name = galleryTagNameFromUrl(href); if (!label || !name || !href) { return null; } const container = tag.closest(domClass.gallery.tagContainer) ?? tag; const tagStyle = tag.computedStyle(); const containerStyle = container.computedStyle(); const myTagId = tag.attribute("data-ehpeek-my-tag-id"); const myTagSet = tag.attribute("data-ehpeek-my-tag-set"); return { data: { appearance: { backgroundColor: containerStyle.backgroundColor, borderColor: containerStyle.borderColor, color: tagStyle.color, }, label, myTag: myTagId && myTagSet ? { id: myTagId, tagSet: myTagSet } : null, name, url: href, }, source: tag, }; }; const readTagGroups = () => { const rows = gallery.tags.rows.all(); if (rows.length > 0) { return rows .map((row) => ({ namespace: row.one(domClass.gallery.tags.rows.namespace)?.text().replace(/:$/, "") || "tag", tags: row.all(domClass.gallery.tags.rows.links).map(readTag).filter((tag) => tag !== null).slice(0, 30), })) .filter((group) => group.tags.length > 0); } return [{ namespace: "tag", tags: gallery.tags.links.all().map(readTag).filter((tag) => tag !== null).slice(0, 60), }].filter((group) => group.tags.length > 0); }; const favoriteColor = (value: string): string | null => { const slot = value.match(/^(?:fav)?([0-9])$/i)?.[1] ?? value.match(/^favorites?\s+([0-9])$/i)?.[1]; return slot === undefined ? null : `var(--color-site-favorite-${slot})`; }; const readFavoriteDialog = ( doc: Document, favorited: boolean, ) => { const dialog = DomNode.from(doc).use(domClass.gallery.favoriteDialog); const options: GalleryFavoriteOption[] = dialog.options.all().map((favoriteInput) => { const row = favoriteInput.closest(domClass.gallery.favoriteDialog.optionRow); const value = favoriteInput.inputValue(); return { color: favoriteColor(value), label: row?.text().replace(/\s+/g, " ") || value, selected: favorited && favoriteInput.checked(), value, }; }); return { note: dialog.note.one()?.inputValue() ?? "", options, }; }; const manageTagGroups = (): GalleryInfoTagGroup[] => readTagGroups().map((group) => ({ namespace: group.namespace, tags: group.tags.map(({ data: tag, source }) => ({ ...tag, contentSource: source.inplace(), })), })); const meta = readMeta(); const category = source.category.one(); const categoryStyle = source.category.appearance.one() ?? category; const cover = source.cover.one(); const coverSource = source.cover.image.one(); const favorite = source.favorite.one(); const ratingCount = source.rating.count.one(); const ratingImage = source.rating.image.one(); const ratingLabel = source.rating.label.one(); const ratingActions = source.rating.actions.all(); const uploader = source.uploader.link.one(); const newTagButton = source.newTag.button.one(); const newTagField = source.newTag.field.one(); const newTagForm = source.newTag.form.one(); const scripts = page.all(domClass.common.scripts).map((pageScript) => pageScript.text()); const actionSources = readActions(); const tagContentSources: DomNode[] = []; const tagGroups = readTagGroups().map((group) => ({ namespace: group.namespace, tags: group.tags.map(({ data: tag, source }) => { const contentSourceIndex = tagContentSources.push(source) - 1; return { ...tag, contentSourceIndex }; }), })); const data = { category: category?.text() ?? "", categoryAppearance: readCategory(categoryStyle), categoryUrl: readCategoryUrl(categoryStyle), favorite: readFavorite(favorite, scripts), rating: readRating(ratingCount, ratingImage, ratingLabel, scripts), summary: [ meta.language, preview?.totalImages ? `${preview.totalImages} ${texts.gallery.pages.toLowerCase()}` : undefined, meta.fileSize, meta.favorited, meta.posted ?? meta.parent, ] .filter((value): value is string => Boolean(value)) .slice(0, 6) .map((value) => ({ value })), tagGroups, titleMain: source.titleMain.one()?.text() ?? "", titleSub: source.titleSub.one()?.text() ?? "", uploader: uploader?.text() ?? "", uploaderUrl: uploader?.attribute("href") ?? "", }; const coverUrl = readCoverUrl(cover, coverSource); const managedCover = coverUrl ? (source.cover.image.clone()?.replaceClasses("").apply("fit") ?? createManagedElement("img", { fit: "ehpeek-fit-gallery-cover" }).apply("fit")) : null; managedCover ?.removeAttributes("id", "style", "width", "height") .setAttributes({ alt: "", decoding: "async", loading: "eager", src: coverUrl }); const managedNewTag = newTagButton && newTagField && newTagForm ? source.newTag.inplace()?.apply("layout") ?? null : null; const hostApply = { hide: "ehpeek-hide-original-gallery-info" } as const; managedNewTag ?.setHidden(false) .removeStyles("display"); const elems = { actionItems: actionSources.map((item) => item.move(domClass.gallery.actions.items.apply).apply("layout")), cover: managedCover, host: host.inplace(hostApply).apply("hide"), mount, newTag: managedNewTag, ratingActions: ratingActions.map((action) => action.inplace()), tagContents: tagContentSources.map((source) => source.inplace()), tagList: gallery.tags.inplace(), tagMenuAction: source.tagMenu.inplace(), } satisfies ManagedDomElements; let selectedTagSource: ManagedDomNode | null = null; const activateTagMenu = (source: ManagedDomNode) => { const stopNavigation = source.listen("click", (event) => { event.preventDefault(); }, { once: true }); source.click(); stopNavigation(); }; const handle = { /** Normalizes the original cover for GalleryInfoPanel's responsive layout. */ /** Hides original GalleryInfo children and installs the component mount. */ installGalleryInfoPanel() { elems.host.prepend(elems.mount); }, /** Loads the original favorite categories and note for EhPeek's favorite modal. */ async loadGalleryFavoriteDialog(actionUrl: string, favorited: boolean) { const response = await requestPage(actionUrl); return readFavoriteDialog(response.document, favorited); }, /** Submits a tag to the chosen My Tags collection and validates the response. */ async submitFavoriteTag( tag: GalleryTagData, tagSet: string, mode: MyTagMode, ) { const response = await addMyTag(tag.name, tagSet, mode); return extractMyTagsPageData(response.document, tagSet); }, /** Keeps component tag groups synchronized with original-page tag updates. */ observeGalleryTagGroups(onChange: (groups: GalleryInfoTagGroup[]) => void) { return elems.tagList?.observe(() => { gallery.tags.rows.requery(); gallery.tags.links.requery(); onChange(manageTagGroups()); }) ?? (() => undefined); }, /** Activates E-H's original rating area and lets its page script submit the vote. */ submitGalleryRating(value: number): void { const rating = Math.round(value * 2); if (rating < 1 || rating > 10) { throw new RangeError("Gallery rating must be between 0.5 and 5 stars."); } const action = elems.ratingActions[rating - 1]; if (!action) { throw new Error("Gallery rating action is unavailable."); } action.click(); }, /** Removes the selected tag from its stored My Tags collection. */ async removeFavoriteTag(tag: GalleryTagData) { if (!tag.myTag) { throw new Error("The tag is not in My Tags."); } const response = await deleteMyTag(tag.myTag.id, tag.myTag.tagSet); return extractMyTagsPageData(response.document, tag.myTag.tagSet); }, /** Opens E-H's original tag actions and only adapts their presentation. */ openGalleryTagMenu( tag: GalleryInfoTagGroup["tags"][number], ): void { if (!elems.tagMenuAction) { throw new Error("Gallery tag actions are unavailable."); } // E-H only creates the action links when its original tag anchor is activated. activateTagMenu(tag.contentSource); elems.newTag?.setHidden(false).removeStyles("display"); const actions = elems.tagMenuAction.all(domClass.gallery.info.tagMenu.actions); if (actions.length === 0) { activateTagMenu(tag.contentSource); elems.newTag?.setHidden(false).removeStyles("display"); throw new Error("Gallery tag actions could not be opened."); } selectedTagSource = tag.contentSource; elems.tagMenuAction.apply("layout"); const addNewTag = actions.find((action) => action.readAttribute("onclick")?.includes("toggle_tagmenu")); addNewTag?.hideOriginal(); }, /** Closes E-H's selected tag without replacing its action DOM. */ closeGalleryTagMenu(): void { if (selectedTagSource) { activateTagMenu(selectedTagSource); } elems.newTag?.setHidden(false).removeStyles("display"); selectedTagSource = null; }, /** Updates the Gallery favorite state through the original site endpoint. */ updateGalleryFavorite, }; return { data, elems, handle }; } export type GalleryInfoDom = NonNullable>; export type GalleryInfoTagGroup = { namespace: string; tags: Array; }; /** Marks the original Gallery page as owned by the TouchUI layout. */ export function mutateGalleryTouchLayout(fitToViewport: boolean): void { const page = DomNode.from(document).use(domClass.page); const html = page.html.inplace(); const body = page.body.inplace(); if (!html || !body) { throw new Error("Gallery page layout is unavailable."); } html.apply("galleryTouchLayout"); body.apply("galleryTouchLayout"); if (fitToViewport) { html.apply("fitToViewport"); } } /** Groups GalleryInfo, Comments, and Preview into independent responsive columns. */ export function mutateGalleryWideLayout( info: GalleryInfoDom, preview: GalleryPreviewDom, initiallyEnabled: boolean, replacesOriginalPreview: boolean, ) { const page = DomNode.from(document).use(domClass.page); const source = DomNode.from(document).use(domClass.gallery); const html = page.html.inplace(); const body = page.body.inplace(); const footer = page.footer.inplace(); const comments = source.comments.inplace(); const commentsAnchor = source.commentsAnchor.inplace(); const pageBarTopHost = source.preview.pageBarTop.one()?.parent()?.inplace() ?? null; const pageBarBottomHost = source.preview.pageBarBottom.one()?.parent()?.inplace() ?? null; const previewMount = preview.elems.mount; const thumbs = preview.elems.thumbs; if (!html || !body || !comments || !previewMount || !thumbs) { return null; } const leftNodes = [info.elems.host, commentsAnchor, comments] .filter((node) => node !== null); const rightNodes = ( replacesOriginalPreview ? [previewMount] : [pageBarTopHost, previewMount, thumbs, pageBarBottomHost] ).filter((node) => node !== null); let layout: ManagedDomNode | null = null; let positions: Array<{ marker: ManagedDomNode; node: ManagedDomNode; }> = []; let enabled = initiallyEnabled; const update = () => { if (enabled && !layout) { layout = createAnchor("gallery-wide-layout") ?.replaceClasses("ehpeek-touch-gallery-layout") ?? null; if (!layout) { return; } window.scrollTo(0, 0); html.apply("galleryWideLayout"); body.apply("galleryWideLayout"); const left = createManagedElement("div") .replaceClasses("ehpeek-touch-gallery-layout-left"); const right = createManagedElement("div") .replaceClasses("ehpeek-touch-gallery-layout-right"); const nodes = [...leftNodes, ...rightNodes, footer] .filter((node) => node !== null); positions = nodes.map((node) => { const marker = createManagedElement("span").setHidden(true); node.before(marker); return { marker, node }; }); info.elems.host.before(layout); layout.append(left, right, ...(footer ? [footer] : [])); left.append(...leftNodes); right.append(...rightNodes); // Moving the Gallery into columns changes its available width without a // viewport resize, so notify page scripts that size their original DOM. window.dispatchEvent(new Event("resize")); return; } if (!enabled && layout) { for (const { marker, node } of positions) { marker.after(node); marker.remove(); } positions = []; layout.remove(); layout = null; html.removeClasses("ehpeek-gallery-wide-layout-root"); body.removeClasses("ehpeek-gallery-wide-layout-root"); window.dispatchEvent(new Event("resize")); } }; update(); return { updateEnabled(value: boolean): void { enabled = value; update(); }, }; } export type GalleryWideLayoutHandle = NonNullable< ReturnType >; /** Keeps Gallery Comments interactions on the current TouchUI page. */ export function manageGalleryCommentsTouch(onLoadError: (error: unknown) => void) { const gallery = DomNode.from(document).use(domClass.gallery); const comments = gallery.comments.inplace(); comments?.apply("touchScore"); const showAllSource = gallery.comments.showAll.one(); const showAll = showAllSource?.inplace() ?? null; const showAllButton = showAllSource?.clone() ?? null; const showAllUrl = showAll?.readAttribute("href"); let loadingAll = false; if (comments && showAll && showAllUrl) { if (showAllButton) { gallery.commentActions.inplace()?.append(showAllButton); } const loadAll = (event: MouseEvent) => { event.preventDefault(); if (loadingAll) { return; } loadingAll = true; showAll.setAttributes({ "aria-busy": "true" }); showAllButton?.setAttributes({ "aria-busy": "true" }); void requestPage(showAllUrl).then((response) => { const loaded = DomNode.from(response.document) .use(domClass.gallery.comments) .one(); if (!loaded) { throw new Error("Cannot read all gallery comments"); } comments.replaceChildren(...loaded.children().map((child) => child.move())); manageGalleryCommentsTouch(onLoadError); }).catch(onLoadError).finally(() => { loadingAll = false; showAll.removeAttributes("aria-busy"); showAllButton?.removeAttributes("aria-busy"); }); }; showAll.listen("click", loadAll); showAllButton?.listen("click", loadAll); } const items = gallery.comments.score.all() .filter((trigger) => trigger.attribute("data-ehpeek-touch-comment-score") !== "true") .map((trigger) => ({ trigger, details: trigger.closest(domClass.gallery.comments.scoreComment) ?.one(domClass.gallery.comments.scoreComment.details) ?? null, })) .filter((item): item is { trigger: DomNode; details: DomNode } => item.details !== null) .map(({ trigger, details }) => ({ details: details.inplace(), detailsId: details.attribute("id") ?? "", expanded: false, trigger: trigger.inplace(), })); const setExpanded = (item: (typeof items)[number], expanded: boolean) => { item.expanded = expanded; item.trigger.attribute("aria-expanded", String(expanded)); item.details.attribute("aria-hidden", String(!expanded)); }; for (const item of items) { item.trigger .removeAttributes("onmouseover", "onmouseout", "onclick") .setAttributes({ "data-ehpeek-touch-comment-score": "true", role: "button", tabindex: "0", "aria-controls": item.detailsId, }); setExpanded(item, false); const toggle = (event: Event) => { event.preventDefault(); event.stopImmediatePropagation(); const shouldExpand = !item.expanded; for (const candidate of items) { setExpanded(candidate, candidate === item && shouldExpand); } }; item.trigger.listen("click", toggle); item.trigger.listen("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { toggle(event); } }); } }