diff --git a/packages/survey-core/entries/chunks/model.ts b/packages/survey-core/entries/chunks/model.ts index 49e5f36f42..379121fc84 100644 --- a/packages/survey-core/entries/chunks/model.ts +++ b/packages/survey-core/entries/chunks/model.ts @@ -228,6 +228,7 @@ export { QuestionImagePickerModel, ImageItemValue } from "../../src/question_imagepicker"; +export { QuestionImageMapModel } from "../../src/question_imagemap"; export { QuestionImageModel } from "../../src/question_image"; export { QuestionSignaturePadModel } from "../../src/question_signaturepad"; export { diff --git a/packages/survey-core/src/default-theme/blocks/sd-imagemap.scss b/packages/survey-core/src/default-theme/blocks/sd-imagemap.scss new file mode 100644 index 0000000000..a61efaebfb --- /dev/null +++ b/packages/survey-core/src/default-theme/blocks/sd-imagemap.scss @@ -0,0 +1,44 @@ +.sd-imagemap { + position: relative; +} + +.sd-imagemap-background { + display: block; + width: 100%; +} + +.sd-imagemap-canvas-preview { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +.sd-imagemap-canvas-selected { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +.sd-imagemap-canvas-hover { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 1; + transition: opacity 0.4s ease; +} +.sd-imagemap-canvas-hover--hidden { + opacity: 0; + transition: none; +} + +.sd-imagemap-map { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; +} diff --git a/packages/survey-core/src/default-theme/default.fontless.scss b/packages/survey-core/src/default-theme/default.fontless.scss index 6866cb4823..aacb16625e 100644 --- a/packages/survey-core/src/default-theme/default.fontless.scss +++ b/packages/survey-core/src/default-theme/default.fontless.scss @@ -70,6 +70,7 @@ @use "blocks/sd-components-container.scss"; @use "blocks/sd-breadcrumbs.scss"; @use "blocks/sd-summary.scss"; +@use "blocks/sd-imagemap.scss"; @use "./default.m600.scss"; body { @@ -162,4 +163,4 @@ body { .sv-components-container-right, .sv-components-container-left { width: fit-content; -} \ No newline at end of file +} diff --git a/packages/survey-core/src/defaultCss/defaultCss.ts b/packages/survey-core/src/defaultCss/defaultCss.ts index 2251939cd4..812ddd33d5 100644 --- a/packages/survey-core/src/defaultCss/defaultCss.ts +++ b/packages/survey-core/src/defaultCss/defaultCss.ts @@ -944,6 +944,17 @@ export var defaultCss = { hintSuffix: "sd-dropdown__hint-suffix sd-tagbox__hint-suffix", hintSuffixWrapper: "sd-tagbox__hint-suffix-wrapper" }, + imagemap: { + root: "sd-imagemap", + background: "sd-imagemap-background", + map: "sd-imagemap-map", + canvas: { + preview: "sd-imagemap-canvas-preview", + selected: "sd-imagemap-canvas-selected", + hover: "sd-imagemap-canvas-hover", + hoverHidden: "sd-imagemap-canvas-hover--hidden" + } + } }; export const defaultThemeName = "default"; diff --git a/packages/survey-core/src/propertyNameArray.ts b/packages/survey-core/src/propertyNameArray.ts new file mode 100644 index 0000000000..5f256c6474 --- /dev/null +++ b/packages/survey-core/src/propertyNameArray.ts @@ -0,0 +1,65 @@ + +export class PropertyNameArray { + + public val; + private name; + + constructor(val: any[] | undefined, name?: string) { + this.val = val ?? []; + this.name = name; + } + + public add(val: any): any[] { + if (this.contains(val)) return this.val; + if (!this.name) { + this.val.push(val); + } else { + this.val.push({ [this.name]: val }); + } + return this.val; + } + + public indexOf(val: any): number { + + for (let i = 0; i < this.val.length; i++) { + let item = this.val[i]; + if (this.name && typeof item === "object") { + if (item[this.name] === val) return i; + } else { + if (item === val) return i; + } + } + return -1; + } + + public contains(val: any): boolean { + return this.indexOf(val) > -1; + } + + public toggle(val: any, max: number = 0): any[] { + if (this.contains(val)) return this.remove(val); + if (max > 0 && this.val.length >= max) return this.val; + return this.add(val); + } + + public remove(val: any): any[] { + const index = this.indexOf(val); + if (index > -1) { + this.val.splice(index, 1); + } + return this.val; + } + + public convert(val: any): any[] { + if (val === undefined || val === null) return val; + if (!Array.isArray(val)) val = [val]; + if (!this.name) return val; + return val.map((item: any) => { + if (item && typeof item === "object" && this.name in item) { + return item; + } else { + return { [this.name]: item }; + } + }); + } +} \ No newline at end of file diff --git a/packages/survey-core/src/question_imagemap.ts b/packages/survey-core/src/question_imagemap.ts new file mode 100644 index 0000000000..af362a540b --- /dev/null +++ b/packages/survey-core/src/question_imagemap.ts @@ -0,0 +1,425 @@ +import { DomDocumentHelper, DomWindowHelper } from "./global_variables_utils"; +import { ItemValue } from "./itemvalue"; +import { property, Serializer } from "./jsonobject"; +import { Question } from "./question"; +import { SurveyModel } from "./survey"; +import { PropertyNameArray } from "../src/propertyNameArray"; +import { SurveyError } from "./survey-error"; +import { CustomError } from "./error"; +import { settings } from "./settings"; + +type DrawStyle = { strokeColor: string, fillColor: string, strokeLineWidth: number } + +export class QuestionImageMapModel extends Question { + constructor(name: string) { + super(name); + this.createItemValues("imageMap"); + } + + backgroundImage: HTMLImageElement; + + previewCanvas: HTMLCanvasElement; + selectedCanvas: HTMLCanvasElement; + hoverCanvas: HTMLCanvasElement; + + imageMapMap: HTMLMapElement; + hoveredItemValue: string; + + public getType(): string { + return "imagemap"; + } + + public supportResponsiveness(): boolean { + return true; + } + + protected getObservedElementSelector(): string { + return "#imagemap-" + this.id + "-background"; + } + + public processResponsiveness() { + this.renderImageMap(); + } + + protected onValueChanged(): void { + super.onValueChanged(); + this.renderSelectedCanvas(); + } + + protected onPropertyValueChanged(name: string, oldValue: any, newValue: any): void { + super.onPropertyValueChanged(name, oldValue, newValue); + if (name === "multiSelect") { + this.clearValue(); + } + } + + protected getValueCore() { + var value = super.getValueCore(); + if (!this.multiSelect && Array.isArray(value) && value.length === 0) { + return undefined; + } + return value; + } + + public afterRenderQuestionElement(el: HTMLElement) { + if (DomWindowHelper.isAvailable()) { + if (!!el) { + if (!this.isDesignMode) { + this.initImageMap(el); + } + this.element = el; + } + } + super.afterRenderQuestionElement(el); + } + + public initImageMap(el: HTMLElement): void { + + if (!el) return; + + this.backgroundImage = el.querySelector(`#imagemap-${this.id}-background`) as HTMLImageElement; + this.previewCanvas = el.querySelector(`#imagemap-${this.id}-canvas-preview`) as HTMLCanvasElement; + this.selectedCanvas = el.querySelector(`#imagemap-${this.id}-canvas-selected`) as HTMLCanvasElement; + this.hoverCanvas = el.querySelector(`#imagemap-${this.id}-canvas-hover`) as HTMLCanvasElement; + this.imageMapMap = el.querySelector("map") as HTMLMapElement; + + this.imageMapMap.onclick = (event) => { + let target = event.target as HTMLElement; + let value = target.dataset.value; + let item = this.imageMap.find(i => i.value === value); + this.mapItemTooggle(item); + }; + + this.imageMapMap.onmouseover = (event: MouseEvent) => { + this.hoveredItemValue = (event.target as HTMLElement).dataset.value; + this.renderPreviewCanvas(); + this.renderHoverCanvas(); + }; + + this.imageMapMap.onmouseout = (event) => { + this.hoveredItemValue = null; + this.renderPreviewCanvas(); + this.renderHoverCanvas(); + }; + + this.backgroundImage.onload = (event) => { + this.renderImageMap(); + this.renderPreviewCanvas(); + this.renderSelectedCanvas(); + this.renderHoverCanvas(); + }; + } + + public scaleCoords(coords: number[]): number[] { + let scale = this.backgroundImage.width / this.backgroundImage.naturalWidth; + return coords.map((coord) => coord * scale); + } + + public drawShape(canvas: HTMLCanvasElement, shape: string, coords: number[], style: DrawStyle): void { + + let ctx = canvas.getContext("2d"); + + ctx.beginPath(); + + ctx.strokeStyle = style.strokeColor; + ctx.lineWidth = style.strokeLineWidth; + + ctx.fillStyle = style.fillColor; + + switch(shape) { + case "rect": + ctx.rect(coords[0], coords[1], coords[2] - coords[0], coords[3] - coords[1]); + break; + case "circle": + ctx.arc(coords[0], coords[1], coords[2], 0, 2 * Math.PI); + break; + case "poly": + for (let i = 0; i < coords.length; i += 2) { + ctx.lineTo(coords[i], coords[i + 1]); + } + ctx.closePath(); + break; + } + + ctx.stroke(); + ctx.fill(); + } + + public clearCanvas(canvas: HTMLCanvasElement): void { + let ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + public renderImageMap(): void { + this.imageMapMap.innerHTML = ""; + if (!this.imageMap) return; + for (let item of this.imageMap) { + let area = DomDocumentHelper.createElement("area") as HTMLAreaElement; + area.shape = item.shape; + area.coords = this.scaleCoords(item.coords.split(",").map(Number)).join(","); + if (item.text) { + area.title = item.text; + } + area.dataset["value"] = item.value; + this.imageMapMap.appendChild(area); + } + } + + public renderPreviewCanvas(): void { + if (!this.previewCanvas) return; + this.clearCanvas(this.previewCanvas); + this.previewCanvas.width = this.backgroundImage.naturalWidth; + this.previewCanvas.height = this.backgroundImage.naturalHeight; + + if (!this.imageMap) return; + for (const item of this.imageMap) { + this.drawShape(this.previewCanvas, item.shape, item.coords.split(",").map(Number), item.getPreviewStyle()); + } + } + + public renderSelectedCanvas(): void { + if (!this.selectedCanvas) return; + this.clearCanvas(this.selectedCanvas); + this.selectedCanvas.width = this.backgroundImage.naturalWidth; + this.selectedCanvas.height = this.backgroundImage.naturalHeight; + if (!this.imageMap) return; + for (const item of this.imageMap) { + if (!this.isItemSelected(item)) continue; + this.drawShape(this.selectedCanvas, item.shape, item.coords.split(",").map(Number), item.getSelectedStyle()); + } + } + + public renderHoverCanvas(): void { + if (!this.hoverCanvas) return; + this.clearCanvas(this.hoverCanvas); + this.hoverCanvas.width = this.backgroundImage.naturalWidth; + this.hoverCanvas.height = this.backgroundImage.naturalHeight; + + if (!this.hoveredItemValue) return; + const items = this.imageMap.filter(i => i.value === this.hoveredItemValue); + for (const item of items) { + this.drawShape(this.hoverCanvas, item.shape, item.coords.split(",").map(Number), item.getHoverStyle()); + } + + this.hoverCanvas.classList.add("sd-imagemap-canvas-hover--hidden"); + this.hoverCanvas.getBoundingClientRect(); // Trigger reflow to restart the hover animation + this.hoverCanvas.classList.remove("sd-imagemap-canvas-hover--hidden"); + } + + @property() imageLink: string; + + public get imageMap(): ImageMapItem[] { + return this.getPropertyValue("imageMap"); + } + public set imageMap(val: ImageMapItem[]) { + this.setPropertyValue("imageMap", val); + } + + @property() valuePropertyName: string; + + @property({ defaultValue: true }) multiSelect: boolean; + + public get maxSelectedChoices(): number { + return this.getPropertyValue("maxSelectedChoices"); + } + public set maxSelectedChoices(val: number) { + if (val < 0) val = 0; + this.setPropertyValue("maxSelectedChoices", val); + } + + public get minSelectedChoices(): number { + return this.getPropertyValue("minSelectedChoices"); + } + public set minSelectedChoices(val: number) { + if (val < 0) val = 0; + this.setPropertyValue("minSelectedChoices", val); + } + + @property() previewStrokeColor: string; + @property() previewStrokeSize: number; + @property() previewFillColor: string; + + @property() hoverStrokeColor: string; + @property() hoverStrokeSize: number; + @property() hoverFillColor: string; + + @property() selectedStrokeColor: string; + @property() selectedStrokeSize: number; + @property() selectedFillColor: string; + + protected onCheckForErrors(errors: Array, isOnValueChanged: boolean, fireCallback: boolean): void { + super.onCheckForErrors(errors, isOnValueChanged, fireCallback); + if (this.multiSelect) { + const length = Array.isArray(this.value) ? this.value.length : 0; + if (this.maxSelectedChoices > 0 && length > this.maxSelectedChoices) { + errors.push(new CustomError( + this.getLocalizationFormatString("maxSelectError", this.maxSelectedChoices), + this + )); + } + if (this.minSelectedChoices > 0 && length < this.minSelectedChoices) { + errors.push(new CustomError( + this.getLocalizationFormatString("minSelectError", this.minSelectedChoices), + this + )); + } + } + } + + protected convertToCorrectValue(val: any): any { + if (this.multiSelect) { + val = new PropertyNameArray(val, this.valuePropertyName).convert(val); + } + return super.convertToCorrectValue(val); + } + + public mapItemTooggle(item: ImageMapItem): void { + if (!this.multiSelect) { + this.value = (this.value === item.value ? undefined : item.value); + return; + } + + this.value = new PropertyNameArray(this.value, this.valuePropertyName).toggle(item.value, this.maxSelectedChoices); + } + + public isItemSelected(item: ImageMapItem): boolean { + if (!this.multiSelect) return this.value === item.value; + return new PropertyNameArray(this.value, this.valuePropertyName).contains(item.value); + } + + public getDisplayValueCore(keysAsText: boolean, value: any): any { + + if (!value) return value; + if (!Array.isArray(value)) value = [value]; + + value = value.map((e: any) => { + if (typeof e === "object") { + return this.valuePropertyName ? e[this.valuePropertyName] : undefined; + } + return e; + }).filter(e => e !== undefined); + + value = value.map(e =>{ + return this.imageMap.find(item => item.value === e)?.text || undefined; + }).filter(e => e !== undefined).join(settings.choicesSeparator); + + return value; + } +} + +export class ImageMapItem extends ItemValue { + + constructor(value: any) { + super(value); + } + + public getBaseType(): string { + return "imagemapitem"; + } + + @property() shape: string; + @property() coords: string; + + @property() previewFillColor: string; + @property() previewStrokeColor: string; + @property() previewStrokeSize: number; + public getPreviewStyle(): DrawStyle { + const owner = this.locOwner as any; + return { + strokeColor: this.previewStrokeColor ?? owner?.previewStrokeColor ?? "transparent", + fillColor: this.previewFillColor ?? owner?.previewFillColor ?? "transparent", + strokeLineWidth: this.previewStrokeSize ?? owner?.previewStrokeSize ?? 0 + }; + } + + @property() hoverFillColor: string; + @property() hoverStrokeColor: string; + @property() hoverStrokeSize: number; + public getHoverStyle(): DrawStyle { + const owner = this.locOwner as any; + const survey = this.getSurvey() as SurveyModel; + return { + strokeColor: this.getPropertyValue("hoverStrokeColor") ?? owner?.hoverStrokeColor ?? survey?.themeVariables["--sjs-secondary-backcolor"] ?? "#FF00FF", + fillColor: this.getPropertyValue("hoverFillColor") ?? owner?.hoverFillColor ?? survey?.themeVariables["--sjs-secondary-backcolor-light"] ?? "#FF00FF", + strokeLineWidth: this.getPropertyValue("hoverStrokeSize") ?? owner?.hoverStrokeSize ?? 2 + }; + } + + @property() selectedFillColor: string; + @property() selectedStrokeColor: string; + @property() selectedStrokeSize: number; + public getSelectedStyle(): DrawStyle { + const owner = this.locOwner as any; + const survey = this.getSurvey() as SurveyModel; + return { + strokeColor: this.getPropertyValue("selectedStrokeColor") ?? owner?.selectedStrokeColor ?? survey?.themeVariables["--sjs-primary-backcolor"] ?? "#FF00FF", + fillColor: this.getPropertyValue("selectedFillColor") ?? owner?.selectedFillColor ?? survey?.themeVariables["--sjs-primary-backcolor-light"] ?? "#FF00FF", + strokeLineWidth: this.getPropertyValue("selectedStrokeSize") ?? owner?.selectedStrokeSize ?? 2 + }; + } +} + +Serializer.addClass("imagemapitem", + [ + { name: "shape", choices: ["circle", "rect", "poly"], default: "poly" }, + { name: "coords:string", locationInTable: "detail" }, + + { name: "previewFillColor:color", locationInTable: "detail" }, + { name: "previewStrokeColor:color", locationInTable: "detail" }, + { name: "previewStrokeSize:number", locationInTable: "detail" }, + + { name: "hoverFillColor:color", locationInTable: "detail" }, + { name: "hoverStrokeColor:color", locationInTable: "detail" }, + { name: "hoverStrokeSize:number", locationInTable: "detail" }, + + { name: "selectedFillColor:color", locationInTable: "detail" }, + { name: "selectedStrokeColor:color", locationInTable: "detail" }, + { name: "selectedStrokeSize:number", locationInTable: "detail" }, + ], + () => new ImageMapItem(""), + "itemvalue" +); + +Serializer.addClass( + "imagemap", + [ + { name: "imageLink:file", category: "general" }, + { name: "imageMap:imagemapitem[]", category: "general" }, + { name: "multiSelect:boolean", default: true, category: "general" }, + { name: "valuePropertyName", category: "data" }, + + { name: "previewFillColor:color", category: "appearance" }, + { name: "previewStrokeColor:color", category: "appearance" }, + { name: "previewStrokeSize:number", category: "appearance" }, + + { name: "hoverFillColor:color", category: "appearance" }, + { name: "hoverStrokeColor:color", category: "appearance" }, + { name: "hoverStrokeSize:number", category: "appearance" }, + + { name: "selectedFillColor:color", category: "appearance" }, + { name: "selectedStrokeColor:color", category: "appearance" }, + { name: "selectedStrokeSize:number", category: "appearance" }, + + { + name: "maxSelectedChoices:number", + default: 0, + onSettingValue: (obj: any, val: any): any => { + if (val <= 0) return 0; + const min = obj.minSelectedChoices; + return min > 0 && val < min ? min : val; + } + }, + { + name: "minSelectedChoices:number", + default: 0, + onSettingValue: (obj: any, val: any): any => { + if (val <= 0) return 0; + const max = obj.maxSelectedChoices; + return max > 0 && val > max ? max : val; + } + }, + ], + () => new QuestionImageMapModel(""), + "question" +); + diff --git a/packages/survey-core/tests/entries/test.ts b/packages/survey-core/tests/entries/test.ts index d1545a78bd..5d790cfa2f 100644 --- a/packages/survey-core/tests/entries/test.ts +++ b/packages/survey-core/tests/entries/test.ts @@ -50,6 +50,7 @@ export * from "../question_matrix_base_tests"; export * from "../question_matrix_tests"; export * from "../question_tagbox_tests"; export * from "../question_comment_tests"; +export * from "../question_imagemap_tests"; export * from "../cssClassBuilderTests"; export * from "../listModelTests"; export * from "../dropdown_list_model_test"; @@ -82,6 +83,7 @@ export * from "../layout_tests"; export * from "../inputPerPageTests"; export * from "../surveyServiceRemovingTests"; export * from "../surveyStateTests"; +export * from "../propertyNameArray_tests"; // localization import "../../src/localization/russian"; diff --git a/packages/survey-core/tests/propertyNameArray_tests.ts b/packages/survey-core/tests/propertyNameArray_tests.ts new file mode 100644 index 0000000000..35bfb687f2 --- /dev/null +++ b/packages/survey-core/tests/propertyNameArray_tests.ts @@ -0,0 +1,73 @@ + +import { PropertyNameArray } from "../src/propertyNameArray"; + +export default QUnit.module("propertyNameArray"); + +QUnit.test("Check PropertyNameArray add value with name", function (assert) { + const val: any[] = []; + const arr = new PropertyNameArray(val, "test"); + arr.add(1); + arr.add(2); + arr.add(2); + arr.add(3); + assert.deepEqual(arr.val, [{ test: 1 }, { test: 2 }, { test: 3 }], "values are correct after adding"); + arr.toggle(2); + arr.toggle(5); + assert.deepEqual(arr.val, [{ test: 1 }, { test: 3 }, { test: 5 }], "values are correct after toggling"); + val[0].desc = "item 1"; + assert.deepEqual(arr.val, [{ test: 1, desc: "item 1" }, { test: 3 }, { test: 5 }], "values are correct after adding desc"); + assert.strictEqual(val, arr.val, "underlying array is correct"); +}); + +QUnit.test("Check PropertyNameArray add value without name", function (assert) { + const val: any[] = []; + const arr = new PropertyNameArray(val); + arr.add(1); + arr.add(2); + arr.add(2); + arr.add(3); + assert.deepEqual(arr.val, [1, 2, 3], "values are correct after adding"); + arr.toggle(2); + arr.toggle(5); + assert.deepEqual(arr.val, [1, 3, 5], "values are correct after toggling"); + arr.toggle(1); + arr.toggle(3); + arr.toggle(5); + assert.deepEqual(arr.val, [], "values are correct after removing all items"); +}); + +QUnit.test("Check PropertyNameArray emulate image map", function (assert) { + const val: any[] = []; + new PropertyNameArray(val, "state").toggle("TX"); + assert.deepEqual(val, [{ state: "TX" }], "added TX"); + val[0].desc = "Texas"; + assert.deepEqual(val, [{ state: "TX", desc: "Texas" }], "added desc to TX"); + new PropertyNameArray(val, "state").toggle("CA"); + val[1].desc = "California"; + assert.deepEqual(val, [{ state: "TX", desc: "Texas" }, { state: "CA", desc: "California" }], "added CA"); + new PropertyNameArray(val, "state").toggle("TX"); + assert.deepEqual(val, [{ state: "CA", desc: "California" }], "removed TX"); +}); + +QUnit.test("Check PropertyNameArray add value with name", function (assert) { + let val = new PropertyNameArray(undefined, "test").toggle(1); + assert.deepEqual(val, [{ test: 1 }], "values are correct after adding to undefined"); + val = new PropertyNameArray(val, "test").toggle(1); + assert.deepEqual(val, [], "values are correct after removing the only item"); +}); + +QUnit.test("Check PropertyNameArray convert", function (assert) { + + let val = new PropertyNameArray(undefined, "test").convert(["TX"]); + assert.deepEqual(val, [{ test: "TX" }], "values are correct after converting array"); + + val = new PropertyNameArray(undefined, "test").convert("TX"); + assert.deepEqual(val, [{ test: "TX" }], "values are correct after converting string"); + + val = new PropertyNameArray(undefined).convert(["TX"]); + assert.deepEqual(val, ["TX"], "values are correct after converting array without name"); + + val = new PropertyNameArray(undefined).convert("TX"); + assert.deepEqual(val, ["TX"], "values are correct after converting string without name"); +}); + diff --git a/packages/survey-core/tests/question_imagemap_tests.ts b/packages/survey-core/tests/question_imagemap_tests.ts new file mode 100644 index 0000000000..c58d31d26d --- /dev/null +++ b/packages/survey-core/tests/question_imagemap_tests.ts @@ -0,0 +1,583 @@ +import { QuestionImageMapModel } from "../src/question_imagemap"; +import { SurveyModel } from "../src/survey"; + +export default QUnit.module("imagemap"); + +QUnit.test("Register and load from json", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + imageMap: [ + { + value: "val1", + text: "val1_text", + coords: "x1,y1,x2,y2,x3,y3,x4,y4" + }, + { + value: "val2", + text: "val2_text", + shape: "rect", + coords: "x1,y1,x2,y2", + }, + { + value: "val2", + text: "val2_text", + shape: "circle", + coords: "x1,y1,r1" + }, + ] + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.equal(q1.getType(), "imagemap", "type is imagemap"); + assert.equal(q1.imageLink, "imageLink_url", "imageLink is imageLink_url"); + assert.equal(q1.imageMap.length, 3, "imageMap.length is 2"); + + assert.equal(q1.imageMap[0].getType(), "imagemapitem", "areas[0] type is imagemaparea"); + + assert.equal(q1.imageMap[0].value, "val1", "[0].value must be val1"); + assert.equal(q1.imageMap[0].shape, "poly", "default shape must be poly"); + assert.equal(q1.imageMap[0].coords, "x1,y1,x2,y2,x3,y3,x4,y4", "coords must be set"); + + assert.equal(q1.imageMap[1].value, "val2", "[1].value must be val2"); + assert.equal(q1.imageMap[1].shape, "rect", "second item shape must be rect"); +}); + +QUnit.test("Check toggle and multiSelect change", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + imageMap: [ + { + value: "val1", + text: "val1_text", + coords: "x1,y1,x2,y2,x3,y3,x4,y4" + }, + { + value: "val2", + text: "val2_text", + shape: "rect", + coords: "x1,y1,x2,y2" + }, + { + value: "val2", + text: "val2_text", + shape: "circle", + coords: "x1,y1,r1" + }, + ] + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.equal(q1.value, undefined, "value must be undefined initially"); + assert.equal(q1.multiSelect, true, "multiSelect is true by default"); + + q1.mapItemTooggle(q1.imageMap[0]); + assert.deepEqual(q1.value, ["val1"], "value must be ['val1'] after first tooggle"); + assert.equal(q1.isItemSelected(q1.imageMap[0]), true, "must be selected after first click"); + assert.equal(q1.isItemSelected(q1.imageMap[1]), false, "must not be selected after first click"); + + q1.mapItemTooggle(q1.imageMap[0]); + assert.deepEqual(q1.value, [], "value must be [] untoggling off item"); + + q1.mapItemTooggle(q1.imageMap[0]); + q1.mapItemTooggle(q1.imageMap[1]); + assert.deepEqual(q1.value, ["val1", "val2"], "value must be ['val1', 'val2'] after selecting two items"); + + q1.mapItemTooggle(q1.imageMap[0]); + assert.deepEqual(q1.value, ["val2"], "value must be ['val2'] after toggling first item off"); + + q1.multiSelect = false; + assert.equal(q1.multiSelect, false, "multiSelect must be false now"); + assert.equal(q1.value, undefined, "value must be undefined #1"); + + q1.mapItemTooggle(q1.imageMap[0]); + assert.equal(q1.value, "val1", "Single: value must be val1"); + assert.equal(q1.isItemSelected(q1.imageMap[0]), true, "Single: imageMap[0] must be selected"); + assert.equal(q1.isItemSelected(q1.imageMap[1]), false, "Single: imageMap[1] must not be selected"); + + q1.mapItemTooggle(q1.imageMap[0]); + assert.equal(q1.value, undefined, "Single: value must be undefined after toggling off"); + + q1.mapItemTooggle(q1.imageMap[1]); + assert.equal(q1.value, "val2", "Single: value must be val2"); + assert.equal(q1.isItemSelected(q1.imageMap[0]), false, "Single: imageMap[0] must not be selected"); + assert.equal(q1.isItemSelected(q1.imageMap[1]), true, "Single: imageMap[1] must be selected"); + + q1.multiSelect = true; + assert.equal(q1.multiSelect, true, "multiSelect must be true now"); + assert.equal(q1.value, undefined, "value must be undefined #2"); +}); + +QUnit.test("Check scaleCoords", function (assert) { + + const model = new QuestionImageMapModel(""); + const coords = "10,20,30,40,50,60".split(",").map(Number); + + model.backgroundImage = { width: 200, naturalWidth: 100 } as HTMLImageElement; + assert.equal(model.scaleCoords(coords).join(","), coords.map(e => e * 2).join(","), "scale by .5 works"); + + model.backgroundImage = { width: 100, naturalWidth: 200 } as HTMLImageElement; + assert.equal(model.scaleCoords(coords).join(","), coords.map(e => e * .5).join(","), "scale by 2 works"); +}); + +QUnit.test("Check init", function (assert) { + + var done = assert.async(4); + + const model: QuestionImageMapModel = new QuestionImageMapModel(""); + const imageDataURL = "data:image/svg+xml;base64," + btoa(''); + + let container = document.createElement("div"); + container.innerHTML = ` + + + + + + `; + + let renderImageMapCalls = 0; + const renderImageMap = model.renderImageMap; + model.renderImageMap = () => { + renderImageMapCalls++; + renderImageMap.apply(model); + }; + setTimeout(() => { + assert.equal(renderImageMapCalls, 1, "renderImageMap must be called 1 time after initImageMap"); + done(); + }, 10); + + let renderPreviewCanvasCalls = 0; + const renderPreviewCanvas = model.renderPreviewCanvas; + model.renderPreviewCanvas = () => { + renderPreviewCanvasCalls++; + renderPreviewCanvas.apply(model); + }; + setTimeout(() => { + assert.equal(renderPreviewCanvasCalls, 1, "renderPreviewCanvas must be called 1 time after initImageMap"); + done(); + }, 10); + + let renderSelectedCanvasCalls = 0; + const renderSelectedCanvas = model.renderSelectedCanvas; + model.renderSelectedCanvas = () => { + renderSelectedCanvasCalls++; + renderSelectedCanvas.apply(model); + }; + setTimeout(() => { + assert.equal(renderSelectedCanvasCalls, 1, "renderSelectedCanvas must be called 1 time after initImageMap"); + done(); + }, 10); + + let renderHoverCanvasCalls = 0; + const renderHoverCanvas = model.renderHoverCanvas; + model.renderHoverCanvas = () => { + renderHoverCanvasCalls++; + renderHoverCanvas.apply(model); + }; + setTimeout(() => { + assert.equal(renderHoverCanvasCalls, 1, "renderHoverCanvas must be called 1 time after initImageMap"); + done(); + }, 10); + + model.initImageMap(container); +}); + +QUnit.test("Check map render", function (assert) { + + var done = assert.async(); + const imageDataURL = "data:image/svg+xml;base64," + btoa(''); + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageMap: [ + { + "value": "val1", + "coords": "100,200,300,400" + }, + { + "value": "val2", + "shape": "rect", + "coords": "100,200,300,400" + }, + { + "value": "val3", + "shape": "circle", + "coords": "150,200,100" + }, + ] + } + ] + }); + + const q1 = model.getQuestionByName("q1"); + + let container = document.createElement("div"); + container.innerHTML = ` + + + + + `; + + q1.initImageMap(container); + + setTimeout(() =>{ + + let map = container.querySelector("map"); + + assert.equal( + map?.innerHTML, + "", + "Map render correct"); + + q1.backgroundImage.width = 200; + q1.renderImageMap(); + assert.equal( + map?.innerHTML, + "", + "Map render correct (smaller)"); + + q1.backgroundImage.width = 800; + q1.renderImageMap(); + assert.equal( + map?.innerHTML, + "", + "Map render correct (bigger)"); + + done(); + }, 10); +}); + +QUnit.test("draw styles without defaults", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + imageMap: [ + { + value: "val1", + coords: "x1,y1,x2,y2" + }, + { + value: "val2", + coords: "x1,y1,x2,y2", + previewFillColor: "itemPreviewFillColor", + previewStrokeColor: "itemPreviewStrokeColor", + previewStrokeSize: 11, + hoverFillColor: "itemHoverFillColor", + hoverStrokeColor: "itemHoverStrokeColor", + hoverStrokeSize: 22, + selectedFillColor: "itemSelectedFillColor", + selectedStrokeColor: "itemSelectedStrokeColor", + selectedStrokeSize: 33, + }, + ], + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.deepEqual(q1.imageMap[0].getPreviewStyle(), { + "fillColor": "transparent", + "strokeColor": "transparent", + "strokeLineWidth": 0 + }, "default preview style"); + + assert.deepEqual(q1.imageMap[0].getHoverStyle(), { + "fillColor": "#FF00FF", + "strokeColor": "#FF00FF", + "strokeLineWidth": 2 + }, "default hover style"); + + assert.deepEqual(q1.imageMap[0].getSelectedStyle(), { + "fillColor": "#FF00FF", + "strokeColor": "#FF00FF", + "strokeLineWidth": 2 + }, "default selected style"); + + assert.deepEqual(q1.imageMap[1].getPreviewStyle(), { + "fillColor": "itemPreviewFillColor", + "strokeColor": "itemPreviewStrokeColor", + "strokeLineWidth": 11 + }, "defined preview style"); + + assert.deepEqual(q1.imageMap[1].getHoverStyle(), { + "fillColor": "itemHoverFillColor", + "strokeColor": "itemHoverStrokeColor", + "strokeLineWidth": 22 + }, "defined hover style"); + + assert.deepEqual(q1.imageMap[1].getSelectedStyle(), { + "fillColor": "itemSelectedFillColor", + "strokeColor": "itemSelectedStrokeColor", + "strokeLineWidth": 33 + }, "defined selected style"); +}); + +QUnit.test("draw styles with defaults", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + imageMap: [ + { + value: "val1", + coords: "x1,y1,x2,y2" + }, + { + value: "val2", + coords: "x1,y1,x2,y2", + previewFillColor: "itemPreviewFillColor", + previewStrokeColor: "itemPreviewStrokeColor", + previewStrokeSize: 11, + hoverFillColor: "itemHoverFillColor", + hoverStrokeColor: "itemHoverStrokeColor", + hoverStrokeSize: 22, + selectedFillColor: "itemSelectedFillColor", + selectedStrokeColor: "itemSelectedStrokeColor", + selectedStrokeSize: 33, + }, + ], + previewFillColor: "defaultPreviewFillColor", + previewStrokeColor: "defaultPreviewStrokeColor", + previewStrokeSize: 1, + hoverFillColor: "defaultHoverFillColor", + hoverStrokeColor: "defaultHoverStrokeColor", + hoverStrokeSize: 2, + selectedFillColor: "defaultSelectedFillColor", + selectedStrokeColor: "defaultSelectedStrokeColor", + selectedStrokeSize: 3, + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.deepEqual(q1.imageMap[0].getPreviewStyle(), { + "fillColor": "defaultPreviewFillColor", + "strokeColor": "defaultPreviewStrokeColor", + "strokeLineWidth": 1 + }, "default preview style"); + + assert.deepEqual(q1.imageMap[0].getHoverStyle(), { + "fillColor": "defaultHoverFillColor", + "strokeColor": "defaultHoverStrokeColor", + "strokeLineWidth": 2 + }, "default hover style"); + + assert.deepEqual(q1.imageMap[0].getSelectedStyle(), { + "fillColor": "defaultSelectedFillColor", + "strokeColor": "defaultSelectedStrokeColor", + "strokeLineWidth": 3 + }, "default selected style"); + + assert.deepEqual(q1.imageMap[1].getPreviewStyle(), { + "fillColor": "itemPreviewFillColor", + "strokeColor": "itemPreviewStrokeColor", + "strokeLineWidth": 11 + }, "defined preview style"); + + assert.deepEqual(q1.imageMap[1].getHoverStyle(), { + "fillColor": "itemHoverFillColor", + "strokeColor": "itemHoverStrokeColor", + "strokeLineWidth": 22 + }, "defined hover style"); + + assert.deepEqual(q1.imageMap[1].getSelectedStyle(), { + "fillColor": "itemSelectedFillColor", + "strokeColor": "itemSelectedStrokeColor", + "strokeLineWidth": 33, + }, "defined selected style"); +}); + +QUnit.test("Check set value and multiSelect change with valuePropertyName", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + valuePropertyName: "state", + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + q1.value = ["TX"]; + assert.deepEqual(q1.value, [{ state: "TX" }], "value is set correctly as array"); + + q1.value = "TX"; + assert.deepEqual(q1.value, [{ state: "TX" }], "value is set correctly as string"); +}); + +QUnit.test("check defaultValue with valuePropertyName", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageLink: "imageLink_url", + valuePropertyName: "state", + defaultValue: ["TX"] + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.deepEqual(q1.value, [{ state: "TX" }], "defaultValue is set correctly"); +}); + +QUnit.test("check maxSelectedChoices via mapItemTooggle", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + maxSelectedChoices: 2, + imageMap: [ + { + value: "val1", + }, + { + value: "val2", + }, + { + value: "val3", + } + ] + } + ] + }); + + const q1 = model.getQuestionByName("q1"); + + q1.mapItemTooggle(q1.imageMap[0]); + q1.mapItemTooggle(q1.imageMap[1]); + q1.mapItemTooggle(q1.imageMap[2]); + + assert.deepEqual(q1.value, ["val1", "val2"], "the third item is not added, max is 2"); +}); + +QUnit.test("check minSelectedChoices + maxSelectedChoices and errors", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + maxSelectedChoices: 3, + minSelectedChoices: 2, + } + ] + }); + + const q1 = model.getQuestionByName("q1"); + + q1.value = ["val1"]; + assert.equal(q1.validate(), false, "there is only one item, min is 2"); + assert.equal(q1.errors.length, 1, "there is one error"); + + q1.value = ["val1", "val2"]; + assert.equal(q1.validate(), true, "there are two items, min is 2"); + assert.equal(q1.errors.length, 0, "there is no error"); + + q1.value = ["val1", "val2", "val3", "val4"]; + assert.equal(q1.validate(), false, "there are four items, max is 3"); + assert.equal(q1.errors.length, 1, "there is one error"); +}); + +QUnit.test("check getDisplayValue without valuePropertyName", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + imageMap: [ + { + value: "val1", + text: "val1_text", + }, + { + value: "val2", + text: "val2_text", + }, + { + value: "val3", + text: "val3_text", + }, + ] + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.equal(q1.getDisplayValue(false, "val1"), "val1_text", "display value for single item"); + assert.equal(q1.getDisplayValue(false, ["val1", "val2"]), "val1_text, val2_text", "display value for multiple items"); + assert.equal(q1.getDisplayValue(false, ["val1", "val10", "val2"]), "val1_text, val2_text", "display value for multiple items with one wrong"); + assert.equal(q1.getDisplayValue(false, [{ value: "val1" }]), "", "display value for wrong item"); +}); + +QUnit.test("check getDisplayValue with valuePropertyName", function (assert) { + + const model = new SurveyModel({ + elements: [ + { + type: "imagemap", + name: "q1", + valuePropertyName: "state", + imageMap: [ + { + value: "val1", + text: "val1_text", + }, + { + value: "val2", + text: "val2_text", + }, + { + value: "val3", + text: "val3_text", + }, + ] + } + ] + }); + const q1 = model.getQuestionByName("q1"); + + assert.equal(q1.getDisplayValue(false, "val1"), "val1_text", "display value for single item"); + assert.equal(q1.getDisplayValue(false, ["val1", "val2"]), "val1_text, val2_text", "display value for multiple items"); + + assert.equal(q1.getDisplayValue(false, { state: "val1" }), "val1_text", "display value for single (object) item"); + assert.equal(q1.getDisplayValue(false, [{ state: "val1" }]), "val1_text", "display value for single (array) item"); + assert.equal(q1.getDisplayValue(false, [{ state: "val1" }, { state: "val2" }]), "val1_text, val2_text", "display value for multiple items"); + + assert.equal(q1.getDisplayValue(false, { wrong: "val1" }), "", "display value for single wrong (object) item"); + assert.equal(q1.getDisplayValue(false, [{ wrong: "val1" }, { wrong: "val1" }]), "", "display value for single wrong (object) item"); + assert.equal(q1.getDisplayValue(false, [{ wrong: "val1" }, { state: "val2" }, { state: "val10" }]), "val2_text", "display value for multiple items with 2 wrong"); +}); \ No newline at end of file diff --git a/packages/survey-core/tests/question_signaturepadtests.ts b/packages/survey-core/tests/question_signaturepadtests.ts index 2bb9bf4718..8dd273aa92 100644 --- a/packages/survey-core/tests/question_signaturepadtests.ts +++ b/packages/survey-core/tests/question_signaturepadtests.ts @@ -89,7 +89,7 @@ QUnit.test("check allowClear", (assert) => { signaturepad.valueWasChangedFromLastUpload = false; assert.equal(signaturepad.canShowClearButton, false, "canShowClearButton"); - signaturepad.value = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100' width='100'%3E%3Ccircle cx='50' cy='50' r='40' /%3E%3C/svg%3E"; + signaturepad.value = "data:image/svg+xml,%3C xmlns='http://www.w3.org/2000/svg' height='100' width='100'%3E%3Ccircle cx='50' cy='50' r='40' /%3E%3C/svg%3E"; assert.equal(signaturepad.canShowClearButton, true, "canShowClearButton"); signaturepad.allowClear = false; diff --git a/packages/survey-react-ui/entries/react-ui-model.ts b/packages/survey-react-ui/entries/react-ui-model.ts index fba6f7a8dd..c10c1764c2 100644 --- a/packages/survey-react-ui/entries/react-ui-model.ts +++ b/packages/survey-react-ui/entries/react-ui-model.ts @@ -78,6 +78,7 @@ export { PopupSurvey, SurveyWindow } from "../src/react-popup-survey"; export { ReactQuestionFactory } from "../src/reactquestion_factory"; export { ReactElementFactory } from "../src/element-factory"; export { SurveyQuestionImagePicker } from "../src/imagepicker"; +export { SurveyQuestionImageMap } from "../src/reactquestion_imagemap"; export { SurveyQuestionImage } from "../src/image"; export { SurveyQuestionSignaturePad } from "../src/signaturepad"; export { SurveyQuestionButtonGroup } from "../src/reactquestion_buttongroup"; diff --git a/packages/survey-react-ui/src/reactquestion_imagemap.tsx b/packages/survey-react-ui/src/reactquestion_imagemap.tsx new file mode 100644 index 0000000000..6419f15365 --- /dev/null +++ b/packages/survey-react-ui/src/reactquestion_imagemap.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { SurveyQuestionElementBase } from "./reactquestion_element"; +import { ReactQuestionFactory } from "./reactquestion_factory"; +import { QuestionImageMapModel } from "survey-core"; + +export class SurveyQuestionImageMap extends SurveyQuestionElementBase { + + constructor(props: any) { + super(props); + this.state = { width: undefined, height: undefined, scale: undefined }; + } + + protected get question(): QuestionImageMapModel { + return this.questionBase as QuestionImageMapModel; + } + + componentDidMount() { + super.componentDidMount(); + } + + componentWillUnmount() { + super.componentWillUnmount(); + } + + public renderElement(): React.JSX.Element { + + return ( +
(this.setControl(root))}> + + + + + + +
+ ); + } +} + +ReactQuestionFactory.Instance.registerQuestion("imagemap", (props) => { + return React.createElement(SurveyQuestionImageMap, props); +});