diff --git a/src/sass/diff.sass b/src/sass/diff.sass new file mode 100644 index 00000000..909bbca3 --- /dev/null +++ b/src/sass/diff.sass @@ -0,0 +1,7 @@ +@use "colours" + +.vbl-dirty + background-color: colours.$light-blue !important + +input[type=checkbox].vbl-dirty + box-shadow: 0 0 0 2px colours.$blue diff --git a/src/ts/common/ui/modal.ts b/src/ts/common/ui/modal.ts index 2323daee..e7c4cfe0 100644 --- a/src/ts/common/ui/modal.ts +++ b/src/ts/common/ui/modal.ts @@ -3,28 +3,29 @@ import "../../../sass/modal.sass"; import {createOverlay} from "./overlay"; import {UIColour} from "./colour"; -interface ModalElement { - kind: string; +type BaseModalElement = { text: string; colour: UIColour; -} +}; -interface ModalButton extends ModalElement { +type ModalButton = BaseModalElement & { kind: "button"; onClick?: () => Promise; -} +}; -interface ModalInput extends ModalElement { +type ModalInput = BaseModalElement & { kind: "input"; placeholder: string; ensureNonEmpty: boolean; onSelect: (userText: string) => Promise; -} +}; + +type ModalElement = ModalButton | ModalInput; interface ModalOptions { text: string; subText?: string[]; - elements: (ModalButton | ModalInput)[]; + elements: ModalElement[]; onCancel?: () => Promise; colour: UIColour; /** @@ -94,7 +95,7 @@ const createModalInput = (exit: () => void, {text, onSelect, colour, ensureNonEm return container; }; -const createModalElement = (exit: () => void) => (element: ModalButton | ModalInput) => { +const createModalElement = (exit: () => void) => (element: ModalElement) => { if (element.kind === "button") { return createModalButton(exit, element); } else { diff --git a/src/ts/content/entities/bookForm/data/index.ts b/src/ts/content/entities/bookForm/data/index.ts index 55001754..2249ded5 100644 --- a/src/ts/content/entities/bookForm/data/index.ts +++ b/src/ts/content/entities/bookForm/data/index.ts @@ -16,6 +16,11 @@ const FORM_META_DATA_KEY = "___metadata_"; const matchFactory = matchFactoryFromDescriptors(collections, ...physicalDescription); +const extractFromFormDataStrict = matchFactory( + "fromFormDataStrict", + (formData, element) => formData[element.id] ?? false +); + const extractFromFormData = matchFactory("fromFormData", (formData, element) => formData[element.id] ?? element); const extractFromElement = matchFactory( @@ -40,9 +45,13 @@ const getFormMetadata = (document: Document): FormData => ({ const isFormDataElement = (element: FormAreaElement): boolean => element && element.type !== "hidden" && internalIsFormDataElement(null, element); +const getFormDataElements = (_document = document) => getFormElements(_document).filter(isFormDataElement); + +const getFormDataFromElement = (element: FormAreaElement) => extractFromElement({}, element); + const getFormData = (_document = document) => - getFormElements(_document).reduce((formData: FormData, element: any) => { - isFormDataElement(element) && extractFromElement(formData, element); + getFormDataElements(_document).reduce((formData: FormData, element: any) => { + extractFromElement(formData, element); return formData; }, getFormMetadata(_document)); @@ -50,19 +59,23 @@ const insertFormData = (formData: FormData) => { const metaData = formData?.[FORM_META_DATA_KEY] ?? {}; ensureRolesInputCount(metaData); ensurePhysicalDescriptionInputCounts(metaData); - getFormElements(document).forEach((element: any) => { - if (isFormDataElement(element)) { - const {value, checked} = transformIncomingData(extractFromFormData(formData, element), element); - if (element.value !== value || element.checked !== checked) { - element.value = value; - element.checked = checked; - element.dispatchEvent(new Event("change")); - ensureVisible(element); - } + getFormDataElements(document).forEach((element: any) => { + const {value, checked} = transformIncomingData(extractFromFormData(formData, element), element); + if (element.value !== value || element.checked !== checked) { + element.value = value; + element.checked = checked; + element.dispatchEvent(new Event("change")); + ensureVisible(element); } }); }; const ensureVisible = (element: Element) => match(element).case(isCollectionsElement, show).yield(); -export {getFormData, insertFormData}; +export { + getFormData, + getFormDataElements, + getFormDataFromElement, + extractFromFormDataStrict as getFormDataForElement, + insertFormData, +}; diff --git a/src/ts/content/entities/bookForm/data/uniqueData/collections.ts b/src/ts/content/entities/bookForm/data/uniqueData/collections.ts index bc797245..df338467 100644 --- a/src/ts/content/entities/bookForm/data/uniqueData/collections.ts +++ b/src/ts/content/entities/bookForm/data/uniqueData/collections.ts @@ -6,22 +6,28 @@ const COLLECTIONS_KEY = "___collections_"; const isCollectionsElement = (element: Element): boolean => element.id.startsWith(COLLECTIONS_ID_PREFIX); -const extractCollectionsDataFromFormData = (formData: FormData, element) => { +const extractCollectionsDataFromFormDataStrict = (formData: FormData, element) => { const span = element.parentElement.getElementsByTagName("span")[0]; - return formData[COLLECTIONS_KEY]?.[span?.textContent] ?? element; + return formData[COLLECTIONS_KEY]?.[span?.textContent] ?? false; }; +const extractCollectionsDataFromFormData = (formData: FormData, element) => + extractCollectionsDataFromFormDataStrict(formData, element) || element; + const extractCollectionsDataFromElement = (formData: FormData, element) => { const collections = formData[COLLECTIONS_KEY] ?? {}; const span = element.parentElement.getElementsByTagName("span")?.[0]; - collections[span.textContent] = {value: element.value, checked: element.checked}; + const record = {value: element.value, checked: element.checked}; + collections[span.textContent] = record; formData[COLLECTIONS_KEY] = collections; + return record; }; const collections = uniqueFormElement({ predicate: isCollectionsElement, fromElement: extractCollectionsDataFromElement, fromFormData: extractCollectionsDataFromFormData, + fromFormDataStrict: extractCollectionsDataFromFormDataStrict, }); export {collections, isCollectionsElement}; diff --git a/src/ts/content/entities/bookForm/data/uniqueData/physicalDescription/index.ts b/src/ts/content/entities/bookForm/data/uniqueData/physicalDescription/index.ts index 69a0360f..a09b636c 100644 --- a/src/ts/content/entities/bookForm/data/uniqueData/physicalDescription/index.ts +++ b/src/ts/content/entities/bookForm/data/uniqueData/physicalDescription/index.ts @@ -20,25 +20,27 @@ const calculateRow = (element: Element): number => { const fromPhysicalDescriptionElement = (key: string, name: string) => (formData: FormData, element) => { const row = calculateRow(element); - if (row >= 0) { - formData[key] ??= []; - formData[key][row] ??= {}; - formData[key][row][name] ??= {}; - formData[key][row][name] = {value: element.value, checked: element.checked}; - } + formData[key] ??= []; + formData[key][row] ??= {}; + formData[key][row][name] ??= {}; + return (formData[key][row][name] = {value: element.value, checked: element.checked}); }; -const fromPhysicalDescriptionFormData = (key: string, name: string) => (formData: FormData, element) => { +const fromPhysicalDescriptionFormDataStrict = (key: string, name: string) => (formData: FormData, element) => { const row = calculateRow(element); - return formData[key]?.[row]?.[name] ?? element; + return formData[key]?.[row]?.[name] ?? false; }; +const fromPhysicalDescriptionFormData = (key: string, name: string) => (formData: FormData, element) => + fromPhysicalDescriptionFormDataStrict(key, name)(formData, element) || element; + const physicalDescriptionFormElement = (id: string) => (name: string) => { const key = `_vbl_${id}`; return uniqueFormElement({ predicate: isPhysicalDescriptionElement(id, name), fromElement: fromPhysicalDescriptionElement(key, name), fromFormData: fromPhysicalDescriptionFormData(key, name), + fromFormDataStrict: fromPhysicalDescriptionFormDataStrict(key, name), }); }; diff --git a/src/ts/content/entities/bookForm/data/uniqueData/uniqueFormElement.ts b/src/ts/content/entities/bookForm/data/uniqueData/uniqueFormElement.ts index 0eb66c46..5b13eade 100644 --- a/src/ts/content/entities/bookForm/data/uniqueData/uniqueFormElement.ts +++ b/src/ts/content/entities/bookForm/data/uniqueData/uniqueFormElement.ts @@ -3,28 +3,36 @@ import {FormData} from "../../types"; type Predicate = (element) => boolean; type FromFormData = (formData: FormData, element) => {value: string; checked: boolean}; -type FromElement = (formData: FormData, element) => void; +type FromFormDataStrict = (formData: FormData, element) => {value: string; checked: boolean} | false; +type FromElement = (formData: FormData, element) => {value: string; checked: boolean}; interface UniqueFormElementDescriptor { isFormData: () => readonly [Predicate, () => true]; fromFormData: (formData: FormData) => readonly [Predicate, (element) => {value: string; checked: boolean}]; fromElement: (formData: FormData) => readonly [Predicate, (element) => void]; + fromFormDataStrict: ( + formData: FormData + ) => readonly [Predicate, (element) => {value: string; checked: boolean} | false]; } interface UniqueFormElementOptions { predicate: Predicate; fromFormData: FromFormData; fromElement: FromElement; + fromFormDataStrict: FromFormDataStrict; } const uniqueFormElement = ({ predicate, fromElement, fromFormData, + fromFormDataStrict, }: UniqueFormElementOptions): UniqueFormElementDescriptor => ({ isFormData: () => [predicate, () => true] as const, fromFormData: (formData: FormData) => [predicate, (element) => fromFormData(formData, element)] as const, fromElement: (formData: FormData) => [predicate, (element) => fromElement(formData, element)] as const, + fromFormDataStrict: (formData: FormData) => + [predicate, (element) => fromFormDataStrict(formData, element)] as const, }); const matchFactoryFromDescriptors = diff --git a/src/ts/content/entities/bookForm/index.ts b/src/ts/content/entities/bookForm/index.ts index f0d0dded..18d4bd2e 100644 --- a/src/ts/content/entities/bookForm/index.ts +++ b/src/ts/content/entities/bookForm/index.ts @@ -11,7 +11,7 @@ import { } from "./render"; import {formDataEquals, formExists} from "./util"; import {getFormData, insertFormData} from "./data"; -import {OnSave, OffSave} from "./save"; +import {OnSave, OffSave} from "./state"; export type {FormData, ForEachFormElement, FormRenderListener, OnSave, OffSave}; export { diff --git a/src/ts/content/entities/bookForm/render.ts b/src/ts/content/entities/bookForm/render.ts index bc90367d..9fbe2d8f 100644 --- a/src/ts/content/entities/bookForm/render.ts +++ b/src/ts/content/entities/bookForm/render.ts @@ -1,28 +1,40 @@ -import {FormAreaElement} from "./types"; -import {formExists, getForm, getFormElements} from "./util"; -import {createOnSave, OffSave, OnConfirm, OnSave} from "./save"; +import {FormAreaElement, FormData} from "./types"; +import {formExists, getForm, getFormElementsFromSubtree} from "./util"; +import {createFormState, FormState, OffSave, OnConfirm, OnSave} from "./state"; +import {getFormDataElements} from "./data"; type ForEachFormElement = (callback: (element: FormAreaElement) => void) => void; -type FormRenderListener = ( - form: HTMLElement, - forEachElement: ForEachFormElement, - onSave: OnSave, - offSave: OffSave, - onConfirm: OnConfirm -) => void; +type FormRenderEnvironment = { + form: HTMLElement; + forEachElement: ForEachFormElement; + onSave: OnSave; + offSave: OffSave; + onConfirm: OnConfirm; + getCleanFormData: () => FormData; +}; +type FormRenderListener = (env: FormRenderEnvironment) => void; const FORM_RENDER_EVENT = "library-thing-form-rendered"; const FORM_REMOVED_EVENT = "library-thing-form-removed"; -const forEachFormElement: ForEachFormElement = (callback: (element: FormAreaElement) => void): void => - getFormElements(document).forEach(callback); +const forEachFormElement: ForEachFormElement = (callback: (element: FormAreaElement) => void): void => { + getFormDataElements(document).forEach(callback); // view -> edit. things called twice. + state.onFormElement(callback); +}; const listeners = new Map void>(); // kinda gross but i don't have a better idea without a bIG refactor -let save: {onSave: OnSave; offSave: OffSave; onConfirm: OnConfirm}; +let state: FormState; const encloseCallbackArguments = (callback: FormRenderListener) => () => - callback(getForm(document), forEachFormElement, save.onSave, save.offSave, save.onConfirm); + callback({ + form: getForm(document), + forEachElement: forEachFormElement, + onSave: (callback) => state.onSave(callback), + offSave: (callback) => state.offSave(callback), + onConfirm: (callback) => state.onConfirm(callback), + getCleanFormData: () => state.getCleanFormData(), + }); const onFormRender = (callback: FormRenderListener): void => { const listener = encloseCallbackArguments(callback); @@ -45,7 +57,7 @@ const onceFormRemoved = (callback: () => void): void => const handleFormMutation = () => { if (formExists()) { - save = createOnSave(); + state = createFormState(); window.dispatchEvent(new Event(FORM_RENDER_EVENT)); } else { window.dispatchEvent(new Event(FORM_REMOVED_EVENT)); @@ -55,7 +67,18 @@ const handleFormMutation = () => { window.addEventListener("pageshow", () => { const editForm = getForm(document); if (editForm) { - new MutationObserver(handleFormMutation).observe(editForm, {subtree: false, childList: true}); + new MutationObserver(handleFormMutation).observe(editForm, {childList: true}); + new MutationObserver( + (mutations) => + state?.registerFormElement && + mutations.forEach((mutation) => + mutation.addedNodes.forEach( + (node) => + node instanceof HTMLElement && + getFormElementsFromSubtree(node).forEach(state.registerFormElement) + ) + ) + ).observe(editForm, {subtree: true, childList: true}); handleFormMutation(); } }); diff --git a/src/ts/content/entities/bookForm/save.ts b/src/ts/content/entities/bookForm/state.ts similarity index 63% rename from src/ts/content/entities/bookForm/save.ts rename to src/ts/content/entities/bookForm/state.ts index 0ae90879..e18c4fbd 100644 --- a/src/ts/content/entities/bookForm/save.ts +++ b/src/ts/content/entities/bookForm/state.ts @@ -1,8 +1,21 @@ +import {FormAreaElement, FormData} from "./types"; +import {getFormData} from "./data"; + type OnSave = (callback: OnSaveListener) => void; type OffSave = OnSave; type OnConfirm = (callback: OnConfirmedListener) => void; type OnSaveListener = () => Promise; type OnConfirmedListener = () => void; +type FormElementListener = (element: FormAreaElement) => void; +type OnFormElement = (callback: FormElementListener) => void; +type FormState = { + onSave: OnSave; + offSave: OffSave; + onConfirm: OnConfirm; + registerFormElement: FormElementListener; + onFormElement: OnFormElement; + getCleanFormData: () => FormData; +}; const maybeClick = ( @@ -36,16 +49,21 @@ const replaceButton = ( button.insertAdjacentElement("beforebegin", td); }; -const createOnSave = (): {onSave: OnSave; offSave: OffSave; onConfirm: OnConfirm} => { +const createFormState = (): FormState => { + const formData = getFormData(); const listeners: Set = new Set(); const confirmListeners: Set = new Set(); + const formElementListeners: Set = new Set(); replaceButton(document.getElementById("book_editTabTextSave1"), listeners, confirmListeners); replaceButton(document.getElementById("book_editTabTextSave2"), listeners, confirmListeners); const onSave = (callback: OnSaveListener) => listeners.add(callback); const offSave = (callback: OnSaveListener) => listeners.delete(callback); const onConfirm = (callback: OnConfirmedListener) => confirmListeners.add(callback); - return {onSave, offSave, onConfirm}; + const onFormElement = (callback: FormElementListener) => formElementListeners.add(callback); + const registerFormElement = (formAreaElement: FormAreaElement) => + formElementListeners.forEach((callback) => callback(formAreaElement)); + return {onSave, offSave, onConfirm, onFormElement, registerFormElement, getCleanFormData: () => formData}; }; -export type {OnSave, OffSave, OnConfirm}; -export {createOnSave}; +export type {OnSave, OffSave, OnConfirm, FormState, FormElementListener}; +export {createFormState}; diff --git a/src/ts/content/entities/bookForm/util.ts b/src/ts/content/entities/bookForm/util.ts index 3db71363..a627b6db 100644 --- a/src/ts/content/entities/bookForm/util.ts +++ b/src/ts/content/entities/bookForm/util.ts @@ -6,8 +6,10 @@ const getElementsByTag = (parent: HTMLElement) => (tag: string) => Array.from(pa const getElementsByTags = (parent: HTMLElement, tags: string[]) => tags.flatMap(getElementsByTag(parent)); -const getFormElements = (document: Document): FormAreaElement[] => - getElementsByTags(getForm(document), FORM_DATA_ELEMENT_TAGS) as FormAreaElement[]; +const getFormElements = (document: Document): FormAreaElement[] => getFormElementsFromSubtree(getForm(document)); + +const getFormElementsFromSubtree = (element: HTMLElement): FormAreaElement[] => + getElementsByTags(element, FORM_DATA_ELEMENT_TAGS) as FormAreaElement[]; // This is relying on the fact that when the edit form is available, the html matches this selector, // and fails to match in all other cases. This IS brittle. If LibraryThing changes @@ -26,4 +28,4 @@ const getForm = (document: Document): HTMLElement => document.getElementById("bo */ const formDataEquals = (formA: FormData, formB: FormData): boolean => JSON.stringify(formA) === JSON.stringify(formB); -export {getForm, getFormElements, formExists, formDataEquals}; +export {getForm, getFormElements, getFormElementsFromSubtree, formExists, formDataEquals}; diff --git a/src/ts/content/extensions/copy/onEdit.ts b/src/ts/content/extensions/copy/onEdit.ts index b38280c3..1a6b97ef 100644 --- a/src/ts/content/extensions/copy/onEdit.ts +++ b/src/ts/content/extensions/copy/onEdit.ts @@ -42,4 +42,4 @@ const onHoverPasteButton = (editTooltip: (text: string) => void) => async () => } }; -onFormRender((form: HTMLElement) => Array.from(form.getElementsByClassName("book_bitTable")).forEach(appendCopyPaste)); +onFormRender(({form}) => Array.from(form.getElementsByClassName("book_bitTable")).forEach(appendCopyPaste)); diff --git a/src/ts/content/extensions/diff.ts b/src/ts/content/extensions/diff.ts new file mode 100644 index 00000000..19f70834 --- /dev/null +++ b/src/ts/content/extensions/diff.ts @@ -0,0 +1,27 @@ +import "../../../sass/diff.sass"; +import {FormData, onFormRender} from "../entities/bookForm"; +import {FormAreaElement} from "../entities/bookForm/types"; +import {getFormDataForElement, getFormDataFromElement} from "../entities/bookForm/data"; + +const makeOnChange = (formAreaElement: FormAreaElement, getCleanFormData: () => FormData) => { + const savedData = getFormDataForElement(getCleanFormData(), formAreaElement); + return () => { + const {value: afterValue, checked: afterChecked} = getFormDataFromElement(formAreaElement); + if (savedData === false || savedData.checked !== afterChecked || savedData.value !== afterValue) { + formAreaElement.classList.add("vbl-dirty"); + } else { + formAreaElement.classList.remove("vbl-dirty"); + } + }; +}; + +const addDiffListener = + (getCleanFormData: () => FormData) => + (formAreaElement: FormAreaElement): void => { + const onChange = makeOnChange(formAreaElement, getCleanFormData); + formAreaElement.addEventListener("change", onChange); + formAreaElement.addEventListener("input", onChange); + onChange(); + }; + +onFormRender(({forEachElement, getCleanFormData}) => forEachElement(addDiffListener(getCleanFormData))); diff --git a/src/ts/content/extensions/resize.ts b/src/ts/content/extensions/resize.ts index ab7e72c1..db24ce4b 100644 --- a/src/ts/content/extensions/resize.ts +++ b/src/ts/content/extensions/resize.ts @@ -1,4 +1,4 @@ -import {ForEachFormElement, onFormRender} from "../entities/bookForm"; +import {onFormRender} from "../entities/bookForm"; import {debounce} from "../../common/util/debounce"; import {getLastFormRender} from "../util/lastFormRender"; import {AUTHOR_TAG_INPUT_ID} from "./author/authorPage/authorUI"; @@ -82,7 +82,7 @@ const ifTextArea = const beenAWhile = () => getLastFormRender() + ONE_DAY_MS < Date.now(); const clearSizeData = () => config.set(ConfigKey.SizeData, {}); -onFormRender(async (form: HTMLElement, forEachElement: ForEachFormElement) => { +onFormRender(async ({forEachElement}) => { beenAWhile() && (await clearSizeData()); forEachElement(ifTextArea(mutateTextArea(await getSizeData()))); }); diff --git a/src/ts/content/extensions/tags.ts b/src/ts/content/extensions/tags.ts index 4455fec3..f15c0ec9 100644 --- a/src/ts/content/extensions/tags.ts +++ b/src/ts/content/extensions/tags.ts @@ -21,7 +21,7 @@ const getAncestorTags = async (tags: string[]): Promise => { return minimalAncestors.filter((tag) => !existingLowerTags.has(tag.toLowerCase())); }; -onFormRender((form, forEachElement, onSave, offSave) => { +onFormRender(({onSave, offSave}) => { const tagsTextAreaContainerId = "bookedit_tags"; const tagsTextAreaId = "form_tags"; const commentsTextArea = document.getElementById("form_comments") as HTMLTextAreaElement; diff --git a/src/ts/content/extensions/warning.ts b/src/ts/content/extensions/warning.ts index 51094a8c..9f619e5b 100644 --- a/src/ts/content/extensions/warning.ts +++ b/src/ts/content/extensions/warning.ts @@ -1,20 +1,20 @@ -import {FormData, formDataEquals, formExists, getFormData, onceFormRender, onFormRender} from "../entities/bookForm"; +import {formDataEquals, formExists, getFormData, onceFormRender, onFormRender} from "../entities/bookForm"; -let storedFormData: FormData; +let listening: boolean; -const undoEdits = () => (storedFormData = getFormData()); +const stopListening = () => (listening = false); -const addUndoEditListener = () => +const addDeafenListener = () => [ document.getElementById("book_editTabTextEditCancel1"), document.getElementById("book_editTabTextEditCancel2"), document.getElementById("book_editTabTextSave1"), document.getElementById("book_editTabTextSave2"), document.getElementById("book_editTabTextDelete"), // so that it doesn't alert you when you're deleting something (?) - ].forEach((element) => element?.addEventListener("click", undoEdits)); + ].forEach((element) => element?.addEventListener("click", stopListening)); -const onExit = (event: Event) => { - if (formExists() && !formDataEquals(storedFormData, getFormData())) { +const onExit = (getCleanFormData) => (event: Event) => { + if (listening && formExists() && !formDataEquals(getCleanFormData(), getFormData())) { event.returnValue = true; return ( "It looks like you have been editing something. " + "If you leave before saving, your changes will be lost." @@ -22,11 +22,9 @@ const onExit = (event: Event) => { } }; -const addUnloadListener = () => window.addEventListener("beforeunload", onExit); - -onceFormRender(addUnloadListener); -onFormRender((form, forEachElement, onSave, offSave, onConfirm) => { - undoEdits(); - addUndoEditListener(); - onConfirm(undoEdits); +onceFormRender(({getCleanFormData}) => window.addEventListener("beforeunload", onExit(getCleanFormData))); +onFormRender(({onConfirm}) => { + listening = true; + addDeafenListener(); + onConfirm(stopListening); }); diff --git a/src/ts/content/index.ts b/src/ts/content/index.ts index 4d3ea459..434f8721 100644 --- a/src/ts/content/index.ts +++ b/src/ts/content/index.ts @@ -1,5 +1,6 @@ import "mv3-hot-reload/content"; +import "./extensions/diff"; import "./extensions/banner"; import "./extensions/copy"; import "./extensions/pdf";