Skip to content

Commit

Permalink
Merge pull request #7778 from surveyjs/feature/C5124-file-chooser-ima…
Browse files Browse the repository at this point in the history
…ge-question

Work for surveyjs/survey-creator#5124 - implemented file chooser functionality for survey
  • Loading branch information
OlgaLarina authored Feb 1, 2024
2 parents a2d3462 + 7b8705a commit d8b6540
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<ng-template #template>
<label tabindex="0" [class]="question.getChooseFileCss()"
[attr.for]="question.inputId" [attr.aria-label]="question.chooseButtonText" [key2click]>
[attr.for]="question.inputId" [attr.aria-label]="question.chooseButtonText" [key2click]
(click)="question.chooseFile()">
<svg *ngIf="question.cssClasses.chooseFileIconId" [title]="question.chooseButtonText"
[iconName]="question.cssClasses.chooseFileIconId" [size]="'auto'" sv-ng-svg-icon></svg>
<span>{{ question.chooseButtonText }}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
:for="question.inputId"
v-bind:aria-label="question.chooseButtonText"
v-key2click
v-on:click="question.chooseFile()"
>
<sv-svg-icon
v-if="question.cssClasses.chooseFileIconId"
Expand Down
1 change: 1 addition & 0 deletions src/base-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export interface ISurvey extends ITextProcessor, ISurveyErrorOwner {
elementContentVisibilityChanged(element: ISurveyElement): void;
onCorrectQuestionAnswer(question: IQuestion, options: any): void;
processPopupVisiblityChanged(question: IQuestion, popupModel: PopupModel, visible: boolean): void;
chooseFiles(input: HTMLInputElement, callback: (files: File[]) => void, context?: { element: ISurveyElement, item?: any }): void;
}
export interface ISurveyImpl {
getSurveyData(): ISurveyData;
Expand Down
1 change: 1 addition & 0 deletions src/entries/chunks/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export {
loadFileFromBase64,
increaseHeightByContent,
createSvg,
chooseFiles,
sanitizeEditableContent,
IAttachKey2clickOptions
} from "../../utils/utils";
Expand Down
2 changes: 1 addition & 1 deletion src/knockout/components/file/choose-file.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<label tabindex="0" data-bind="css: question.koChooseFileCss, key2click, attr: { for: question.inputId, 'aria-label': question.koGetChooseButtonText() }">
<label tabindex="0" data-bind="css: question.koChooseFileCss, key2click, click: function() { question.chooseFile(); }, attr: { for: question.inputId, 'aria-label': question.koGetChooseButtonText() }">
<!-- ko if: question.cssClasses.chooseFileIconId -->
<!-- ko component: { name: 'sv-svg-icon', params: { title: question.koGetChooseButtonText(), iconName: question.cssClasses.chooseFileIconId, size: 'auto' } } --><!-- /ko -->
<!-- /ko -->
Expand Down
92 changes: 53 additions & 39 deletions src/question_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,11 @@ export class QuestionFileModel extends QuestionFileModelBase {
@propertyArray({}) public previewValue: any[];

@property({ defaultValue: 0 }) indexToShow: number;
@property({ defaultValue: 1, onSet: (_, target) => {
target.updateFileNavigator();
} }) pageSize: number;
@property({
defaultValue: 1, onSet: (_, target) => {
target.updateFileNavigator();
}
}) pageSize: number;
@property({ defaultValue: false }) containsMultiplyFiles: boolean;
@property() allowCameraAccess: boolean;
/**
Expand All @@ -164,11 +166,13 @@ export class QuestionFileModel extends QuestionFileModelBase {
* @see photoPlaceholder
* @see fileOrPhotoPlaceholder
*/
@property({ onSet: (val: string, obj: QuestionFileModel) => {
if(!obj.isLoadingFromJson) {
obj.updateCurrentMode();
@property({
onSet: (val: string, obj: QuestionFileModel) => {
if (!obj.isLoadingFromJson) {
obj.updateCurrentMode();
}
}
} }) sourceType: string;
}) sourceType: string;

public fileNavigator: ActionContainer = new ActionContainer();
protected prevFileAction: Action;
Expand Down Expand Up @@ -300,7 +304,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
public get hasFileUI(): boolean { return this.currentMode !== "camera"; }
private videoStream: MediaStream;
public startVideo(): void {
if(this.currentMode === "file" || this.isDesignMode || this.isPlayingVideo) return;
if (this.currentMode === "file" || this.isDesignMode || this.isPlayingVideo) return;
this.setIsPlayingVideo(true);
setTimeout(() => {
this.startVideoInCamera();
Expand All @@ -310,7 +314,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
private startVideoInCamera(): void {
this.camera.startVideo(this.videoId, (stream: MediaStream) => {
this.videoStream = stream;
if(!stream) {
if (!stream) {
this.stopVideo();
}
}, getRenderedSize(this.imageWidth), getRenderedSize(this.imageHeight));
Expand All @@ -320,9 +324,9 @@ export class QuestionFileModel extends QuestionFileModelBase {
this.closeVideoStream();
}
public snapPicture(): void {
if(!this.isPlayingVideo) return;
if (!this.isPlayingVideo) return;
const blobCallback = (blob: Blob | null): void => {
if(blob) {
if (blob) {
const file = new File([blob], "snap_picture.png", { type: "image/png" });
this.loadFiles([file]);
}
Expand All @@ -332,21 +336,21 @@ export class QuestionFileModel extends QuestionFileModelBase {
}
@property() private canFlipCameraValue: boolean = undefined;
public canFlipCamera(): boolean {
if(this.canFlipCameraValue === undefined) {
if (this.canFlipCameraValue === undefined) {
this.canFlipCameraValue = this.camera.canFlip((res: boolean) => {
this.canFlipCameraValue = res;
});
}
return this.canFlipCameraValue;
}
public flipCamera(): void {
if(!this.canFlipCamera()) return;
if (!this.canFlipCamera()) return;
this.closeVideoStream();
this.camera.flip();
this.startVideoInCamera();
}
private closeVideoStream(): void {
if(!!this.videoStream) {
if (!!this.videoStream) {
this.videoStream.getTracks().forEach(track => {
track.stop();
});
Expand All @@ -372,9 +376,9 @@ export class QuestionFileModel extends QuestionFileModelBase {
}
private prevPreviewLength = 0;
private previewValueChanged() {
if(this.previewValue.length !== this.prevPreviewLength) {
if(this.previewValue.length > 0) {
if(this.prevPreviewLength > this.previewValue.length) {
if (this.previewValue.length !== this.prevPreviewLength) {
if (this.previewValue.length > 0) {
if (this.prevPreviewLength > this.previewValue.length) {
this.indexToShow = this.indexToShow >= this.pagesCount && this.indexToShow > 0 ? this.pagesCount - 1 : this.indexToShow;
} else {
this.indexToShow = Math.floor(this.prevPreviewLength / this.pageSize);
Expand All @@ -385,7 +389,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
}
this.fileIndexAction.title = this.getFileIndexCaption();
this.containsMultiplyFiles = this.previewValue.length > 1;
if(this.previewValue.length > 0 && !this.calculatedGapBetweenItems && !this.calculatedItemWidth) {
if (this.previewValue.length > 0 && !this.calculatedGapBetweenItems && !this.calculatedItemWidth) {
setTimeout(() => {
this.processResponsiveness(0, this._width);
});
Expand Down Expand Up @@ -471,6 +475,16 @@ export class QuestionFileModel extends QuestionFileModelBase {
public set maxSize(val: number) {
this.setPropertyValue("maxSize", val);
}
public chooseFile(): void {
const inputElement = document.getElementById(this.inputId) as HTMLInputElement;
if (inputElement) {
if (this.survey) {
this.survey.chooseFiles(inputElement, files => this.loadFiles(files), { element: this });
} else {
inputElement.click();
}
}
}
/**
* Specifies whether users should confirm file deletion.
*
Expand Down Expand Up @@ -518,19 +532,19 @@ export class QuestionFileModel extends QuestionFileModelBase {

@property() locRenderedPlaceholderValue: LocalizableString;
public get locRenderedPlaceholder(): LocalizableString {
if(this.locRenderedPlaceholderValue === undefined) {
if (this.locRenderedPlaceholderValue === undefined) {
this.locRenderedPlaceholderValue = <LocalizableString><unknown>(new ComputedUpdater<LocalizableString>(() => {
const isReadOnly = this.isReadOnly;
const hasFileUI = (!this.isDesignMode && this.hasFileUI) || (this.isDesignMode && this.sourceType != "camera");
const hasVideoUI = (!this.isDesignMode && this.hasVideoUI) || (this.isDesignMode && this.sourceType != "file");
let renderedPlaceholder: LocalizableString;
if(isReadOnly) {
if (isReadOnly) {
renderedPlaceholder = this.locNoFileChosenCaption;
}
else if(hasFileUI && hasVideoUI) {
else if (hasFileUI && hasVideoUI) {
renderedPlaceholder = this.locFileOrPhotoPlaceholder;
}
else if(hasFileUI) {
else if (hasFileUI) {
renderedPlaceholder = this.locFilePlaceholder;
}
else {
Expand All @@ -551,8 +565,8 @@ export class QuestionFileModel extends QuestionFileModelBase {
this.setPropertyValue("isPlayingVideo", show);
}
private updateCurrentMode(): void {
if(!this.isDesignMode) {
if(this.sourceType !== "file") {
if (!this.isDesignMode) {
if (this.sourceType !== "file") {
this.camera.hasCamera((res: boolean) => {
this.setPropertyValue("currentMode", res && this.isDefaultV2Theme ? this.sourceType : "file");
});
Expand All @@ -573,7 +587,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
return " ";
}

public get chooseButtonText () {
public get chooseButtonText() {
return this.isEmpty() || this.allowMultiple ? this.chooseButtonCaption : this.replaceButtonCaption;
}

Expand Down Expand Up @@ -719,7 +733,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
private cameraValue: Camera;

protected get camera(): Camera {
if(!this.cameraValue) {
if (!this.cameraValue) {
this.cameraValue = new Camera();
}
return this.cameraValue;
Expand Down Expand Up @@ -887,7 +901,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
protected onChangeQuestionValue(newValue: any): void {
super.onChangeQuestionValue(newValue);
this.stateChanged(this.isEmpty() ? "empty" : "loaded");
if(!this.isLoadingFromJson) {
if (!this.isLoadingFromJson) {
this.loadPreview(newValue);
}
}
Expand Down Expand Up @@ -933,28 +947,28 @@ export class QuestionFileModel extends QuestionFileModelBase {
private calculatedItemWidth: number;
private _width: number;
public triggerResponsiveness(hard?: boolean): void {
if(hard) {
if (hard) {
this.calculatedGapBetweenItems = undefined;
this.calculatedItemWidth = undefined;
}
super.triggerResponsiveness();
}
protected processResponsiveness(_: number, availableWidth: number): boolean {
this._width = availableWidth;
if(this.rootElement) {
if((!this.calculatedGapBetweenItems || !this.calculatedItemWidth) && this.allowMultiple) {
if (this.rootElement) {
if ((!this.calculatedGapBetweenItems || !this.calculatedItemWidth) && this.allowMultiple) {
const fileListSelector = this.getFileListSelector();
const fileListElement = fileListSelector ? this.rootElement.querySelector(this.getFileListSelector()) : undefined;
if(fileListElement) {
if (fileListElement) {
this.calculatedGapBetweenItems = Math.ceil(Number.parseFloat(window.getComputedStyle(fileListElement).gap));
const firstVisibleItem = Array.from(fileListElement.children).filter((_, index) => this.isPreviewVisible(index))[0];
if(firstVisibleItem) {
if (firstVisibleItem) {
this.calculatedItemWidth = Math.ceil(Number.parseFloat(window.getComputedStyle(firstVisibleItem).width));
}
}
}
}
if(this.calculatedGapBetweenItems && this.calculatedItemWidth) {
if (this.calculatedGapBetweenItems && this.calculatedItemWidth) {
this.pageSize = this.calcAvailableItemsCount(availableWidth, this.calculatedItemWidth, this.calculatedGapBetweenItems);
return true;
}
Expand All @@ -973,7 +987,7 @@ export class QuestionFileModel extends QuestionFileModelBase {
if (this.canDragDrop()) {
event.preventDefault();
this.isDragging = true;
this.dragCounter ++;
this.dragCounter++;
}
}
onDragOver = (event: any) => {
Expand All @@ -995,8 +1009,8 @@ export class QuestionFileModel extends QuestionFileModelBase {
}
onDragLeave = (event: any) => {
if (this.canDragDrop()) {
this.dragCounter --;
if(this.dragCounter === 0) {
this.dragCounter--;
if (this.dragCounter === 0) {
this.isDragging = false;
}
}
Expand All @@ -1013,9 +1027,9 @@ export class QuestionFileModel extends QuestionFileModelBase {
this.clearFilesCore();
}
private clearFilesCore(): void {
if(this.rootElement) {
if (this.rootElement) {
const input = this.rootElement.querySelectorAll("input")[0];
if(input) {
if (input) {
input.value = "";
}
}
Expand Down Expand Up @@ -1103,7 +1117,7 @@ export class FileLoader {
name: value.name,
type: value.type,
};
downloadedCount ++;
downloadedCount++;
if (downloadedCount === files.length) {
this.callback("loaded", this.loaded);
}
Expand Down
1 change: 1 addition & 0 deletions src/react/components/file/file-choose-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class SurveyFileChooseButton extends ReactSurveyElement {
className={this.question.getChooseFileCss()}
htmlFor={this.question.inputId}
aria-label={this.question.chooseButtonText}
onClick={() => this.question.chooseFile()}
>
{(!!this.question.cssClasses.chooseFileIconId) ? <SvgIcon title={this.question.chooseButtonText} iconName={this.question.cssClasses.chooseFileIconId} size={"auto"}></SvgIcon>: null }
<span>{this.question.chooseButtonText}</span>
Expand Down
19 changes: 19 additions & 0 deletions src/survey-events-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,25 @@ export interface UploadFilesEvent extends LoadFilesEvent {
*/
files: Array<File>;
}
export interface OpenFileChooserEvent {
/**
* A file input HTML element.
*/
input: HTMLInputElement;
/**
* A question, panel, page, or survey for which this event is raised.
*/
element: Base;
/**
* A choice item for which the event is raised. This parameter has a value only when the dialog window is opened to select images for an [Image Picker](https://surveyjs.io/form-library/documentation/api-reference/image-picker-question-model) question.
*/
item: ItemValue;
/**
* A callback function to which you should pass selected files.
* @param files An array of selected files.
*/
callback: (files: Array<File>) => void;
}
export interface DownloadFileEvent extends LoadFilesEvent {
/**
* A callback function that you should call when a file is downloaded successfully or when deletion fails. Pass `"success"` or `"error"` as the first argument to indicate the operation status. As the second argument, you can pass the downloaded file's data as a Base64 string if file download was successful or an error message if file download failed.
Expand Down
Loading

0 comments on commit d8b6540

Please sign in to comment.