overleaf/services/web/frontend/js/ide/pdfng/directives/pdfViewer.js
Jakob Ackermann a6f05109a3 Merge pull request #5352 from overleaf/jpa-no-var
[misc] fix eslint violations for `no-var`

GitOrigin-RevId: c52e82f3a8a993b8662cc5aa56e7b95ca3c55832
2021-10-27 08:03:00 +00:00

861 lines
28 KiB
JavaScript

import _ from 'lodash'
/* eslint-disable
node/handle-callback-err,
max-len,
new-cap,
no-return-assign,
no-sequences,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS104: Avoid inline assignments
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import App from '../../../base'
import { captureMessage } from '../../../infrastructure/error-reporter'
import pdfTextLayer from './pdfTextLayer'
import pdfAnnotations from './pdfAnnotations'
import pdfHighlights from './pdfHighlights'
import pdfRenderer from './pdfRenderer'
import pdfPage from './pdfPage'
import pdfSpinner from './pdfSpinner'
App.controller(
'pdfViewerController',
function (
$scope,
$q,
$timeout,
PDFRenderer,
$element,
pdfHighlights,
pdfSpinner
) {
this.load = function () {
// $scope.pages = []
// Ensure previous document is torn down before loading the next one (to
// prevent race conditions)
const documentTearDown =
$scope.document != null ? $scope.document.destroy() : Promise.resolve()
// Keep track of whether the pdf has loaded (this allows rescale events to
// be ignored until we are ready to render the pdf).
$scope.isLoaded = false
return documentTearDown.then(() => {
$scope.loadCount = $scope.loadCount != null ? $scope.loadCount + 1 : 1
// TODO need a proper url manipulation library to add to query string
let url = $scope.pdfSrc
// add 'pdfng=true' to show that we are using the angular pdfjs viewer
const queryStringExists = /\?/.test(url)
url = url + (!queryStringExists ? '?' : '&') + 'pdfng=true'
// Take references as these must be invoked for this very URL
const firstRenderDone = $scope.firstRenderDone
const updateConsumedBandwidth = $scope.updateConsumedBandwidth
// for isolated compiles, load the pdf on-demand because nobody will overwrite it
const onDemandLoading = true
$scope.document = new PDFRenderer(url, {
scale: 1,
disableAutoFetch: onDemandLoading ? true : undefined,
navigateFn(ref) {
// this function captures clicks on the annotation links
$scope.navigateTo = ref
return $scope.$apply()
},
progressCallback(progress) {
updateConsumedBandwidth(progress.loaded)
return $scope.$emit('progress', progress)
},
loadedCallback() {
return $scope.$emit('loaded')
},
firstRenderDone,
errorCallback(error) {
// MissingPDFException is "expected" as the pdf file can be on a
// CLSI server that has been cycled out.
// Currently, there is NO error handling to handle this situation,
// but we plan to add this in the future
// (https://github.com/overleaf/issues/issues/2985) and this error
// is causing noise in Sentry so ignore it
if (!error || error.name !== 'MissingPDFException') {
captureMessage(`pdfng error ${error}`)
}
return $scope.$emit('pdf:error', error)
},
pageSizeChangeCallback(pageNum, deltaH) {
return $scope.$broadcast('pdf:page:size-change', pageNum, deltaH)
},
})
// we will have all the main information needed to start display
// after the following promise is resolved
$scope.loaded = $q
.all({
numPages: $scope.document.getNumPages(),
// get size of first page as default @ scale 1
pdfViewport: $scope.document.getPdfViewport(1, 1),
})
.then(function (result) {
$scope.pdfViewport = result.pdfViewport
$scope.pdfPageSize = [
result.pdfViewport.height,
result.pdfViewport.width,
]
// console.log 'resolved q.all, page size is', result
$scope.$emit('loaded')
// we can now render the document and handle rescale events
$scope.isLoaded = true
return ($scope.numPages = result.numPages)
})
.catch(function (error) {
$scope.$emit('pdf:error', error)
return $q.reject(error)
})
return $scope.loaded
})
}
this.setScale = (scale, containerHeight, containerWidth) =>
$scope.loaded
.then(function () {
let numScale
if (scale == null) {
scale = {}
}
if (containerHeight === 0 || containerWidth === 0) {
numScale = 1
} else if (scale.scaleMode === 'scale_mode_fit_width') {
// TODO make this dynamic
numScale = (containerWidth - 40) / $scope.pdfPageSize[1]
} else if (scale.scaleMode === 'scale_mode_fit_height') {
// TODO magic numbers for jquery ui layout
numScale = (containerHeight - 20) / $scope.pdfPageSize[0]
} else if (scale.scaleMode === 'scale_mode_value') {
numScale = scale.scale
} else if (scale.scaleMode === 'scale_mode_auto') {
// TODO
} else {
scale.scaleMode = 'scale_mode_fit_width'
numScale = (containerWidth - 40) / $scope.pdfPageSize[1]
}
// TODO
$scope.scale.scale = numScale
$scope.document.setScale(numScale)
return ($scope.defaultPageSize = [
numScale * $scope.pdfPageSize[0],
numScale * $scope.pdfPageSize[1],
])
})
// console.log 'in setScale result', $scope.scale.scale, $scope.defaultPageSize
.catch(function (error) {
$scope.$emit('pdf:error', error)
return $q.reject(error)
})
this.redraw = function (position) {
// console.log 'in redraw'
// console.log 'reseting pages array for', $scope.numPages
$scope.pages = __range__(0, $scope.numPages - 1, true).map(i => ({
pageNum: i + 1,
}))
if (position != null && position.page != null) {
// console.log 'position is', position.page, position.offset
// console.log 'setting current page', position.page
let pagenum = position.page
if (pagenum > $scope.numPages - 1) {
pagenum = $scope.numPages - 1
}
$scope.pages[pagenum].current = true
return ($scope.pages[pagenum].position = position)
}
}
this.zoomIn = function () {
// console.log 'zoom in'
const newScale = $scope.scale.scale * 1.2
return ($scope.forceScale = {
scaleMode: 'scale_mode_value',
scale: newScale,
})
}
this.zoomOut = function () {
// console.log 'zoom out'
const newScale = $scope.scale.scale / 1.2
return ($scope.forceScale = {
scaleMode: 'scale_mode_value',
scale: newScale,
})
}
this.fitWidth = () =>
// console.log 'fit width'
($scope.forceScale = { scaleMode: 'scale_mode_fit_width' })
this.fitHeight = () =>
// console.log 'fit height'
($scope.forceScale = { scaleMode: 'scale_mode_fit_height' })
this.checkPosition = () =>
// console.log 'check position'
($scope.forceCheck = ($scope.forceCheck || 0) + 1)
this.showRandomHighlights = () =>
// console.log 'show highlights'
($scope.highlights = [
{
page: 3,
h: 100,
v: 100,
height: 30,
width: 200,
},
])
// we work with (pagenumber, % of height down page from top)
// pdfListView works with (pagenumber, vertical position up page from
// bottom measured in pts)
this.getPdfPosition = function () {
// console.log 'in getPdfPosition'
let canvasOffset, pdfOffset, viewport
let topPageIdx = 0
let topPage = $scope.pages[0]
// find first visible page
const visible = $scope.pages.some(function (page, i) {
if (page.visible) {
let ref
return ([topPageIdx, topPage] = Array.from((ref = [i, page]))), ref
}
return false
})
if (visible && topPage.element != null) {
// console.log 'found it', topPageIdx
} else {
// console.log 'CANNOT FIND TOP PAGE'
return
}
// console.log 'top page is', topPage.pageNum, topPage.elemTop, topPage.elemBottom, topPage
const { top } = topPage.element.offset()
const bottom = top + topPage.element.innerHeight()
const viewportTop = $element.offset().top
const viewportBottom = viewportTop + $element.height()
const topVisible = top >= viewportTop && top < viewportBottom
const someContentVisible = top < viewportTop && bottom > viewportTop
// console.log 'in PdfListView', top, topVisible, someContentVisible, viewportTop
if (topVisible) {
canvasOffset = 0
} else if (someContentVisible) {
canvasOffset = viewportTop - top
} else {
canvasOffset = null
}
// console.log 'pdfListview position = ', canvasOffset
// instead of using promise, check if size is known and revert to
// default otherwise
// console.log 'looking up viewport', topPage.viewport, $scope.pdfViewport
if (topPage.viewport) {
;({ viewport } = topPage)
pdfOffset = viewport.convertToPdfPoint(0, canvasOffset)
} else {
// console.log 'WARNING: had to default to global page size'
viewport = $scope.pdfViewport
const scaledOffset = canvasOffset / $scope.scale.scale
pdfOffset = viewport.convertToPdfPoint(0, scaledOffset)
}
// console.log 'converted to offset = ', pdfOffset
const newPosition = {
page: topPageIdx,
offset: { top: pdfOffset[1], left: 0 },
pageSize: { height: viewport.viewBox[3], width: viewport.viewBox[2] },
}
return newPosition
}
this.computeOffset = function (page, position) {
// console.log 'computing offset for', page, position
const { element } = page
// console.log 'element =', $(element), 'parent =', $(element).parent()
const t1 = __guard__($(element).offset(), x => x.top)
const t2 = __guard__($(element).parent().offset(), x1 => x1.top)
if (!(t1 != null && t2 != null)) {
return $q((resolve, reject) => reject('elements destroyed'))
}
const pageTop = $(element).offset().top - $(element).parent().offset().top
// console.log('top of page scroll is', pageTop, 'vs', page.elemTop)
// console.log('inner height is', $(element).innerHeight())
const currentScroll = $(element).parent().scrollTop()
const { offset } = position
// convert offset to pixels
return $scope.document
.getPdfViewport(page.pageNum)
.then(function (viewport) {
page.viewport = viewport
const pageOffset = viewport.convertToViewportPoint(
offset.left,
offset.top
)
// if the passed-in position doesn't have the page height/width add them now
if (position.pageSize == null) {
position.pageSize = {
height: viewport.viewBox[3],
width: viewport.viewBox[2],
}
}
// console.log 'addition offset =', pageOffset
// console.log 'total', pageTop + pageOffset[1]
return Math.round(pageTop + pageOffset[1] + currentScroll)
}) // # 10 is margin
}
this.setPdfPosition = function (page, position) {
// console.log 'required pdf Position is', position
return this.computeOffset(page, position).then(function (offset) {
return $scope.$apply(() => {
$scope.pleaseScrollTo = offset
$scope.position = position
})
})
}
return this
}
)
export default App.directive('pdfViewer', ($q, $timeout, pdfSpinner) => ({
controller: 'pdfViewerController',
controllerAs: 'ctrl',
scope: {
pdfSrc: '=',
firstRenderDone: '=',
updateConsumedBandwidth: '=',
highlights: '=',
position: '=',
scale: '=',
pleaseJumpTo: '=',
},
template: `\
<div data-pdf-page class='pdf-page-container page-container' ng-repeat='page in pages'></div>\
`,
link(scope, element, attrs, ctrl) {
// console.log 'in pdfViewer element is', element
// console.log 'attrs', attrs
const spinner = new pdfSpinner()
let layoutReady = $q.defer()
layoutReady.notify('waiting for layout')
layoutReady.promise.then(function () {})
// console.log 'layoutReady was resolved'
const renderVisiblePages = function () {
const visiblePages = getVisiblePages()
const pages = getExtraPages(visiblePages)
return scope.document.renderPages(pages)
}
function getVisiblePages() {
const top = element[0].scrollTop
const bottom = top + element[0].clientHeight
const visiblePages = _.filter(scope.pages, function (page) {
if (page.element == null) {
return false
}
const pageElement = page.element[0]
const pageTop = pageElement.offsetTop
const pageBottom = pageTop + pageElement.clientHeight
page.visible = pageTop < bottom && pageBottom > top
return page.visible
})
return visiblePages
}
function getExtraPages(visiblePages) {
const extra = []
if (visiblePages.length > 0) {
const firstVisiblePage = visiblePages[0].pageNum
const firstVisiblePageIdx = firstVisiblePage - 1
const len = visiblePages.length
const lastVisiblePage = visiblePages[len - 1].pageNum
const lastVisiblePageIdx = lastVisiblePage - 1
// first page after
if (lastVisiblePageIdx + 1 < scope.pages.length) {
extra.push(scope.pages[lastVisiblePageIdx + 1])
}
// page before
if (firstVisiblePageIdx > 0) {
extra.push(scope.pages[firstVisiblePageIdx - 1])
}
// second page after
if (lastVisiblePageIdx + 2 < scope.pages.length) {
extra.push(scope.pages[lastVisiblePageIdx + 2])
}
}
return visiblePages.concat(extra)
}
let rescaleTimer = null
const queueRescale = function (scale) {
// console.log 'call to queueRescale'
if (!scope.isLoaded) {
return // ignore any requests to rescale before the document is loaded
}
if (rescaleTimer != null || layoutTimer != null || elementTimer != null) {
return
}
// console.log 'adding to rescale queue'
return (rescaleTimer = setTimeout(function () {
doRescale(scale)
return (rescaleTimer = null)
}, 0))
}
let spinnerTimer = null
function doRescale(scale) {
// console.log 'doRescale', scale
if (scale == null) {
return
}
const origposition = angular.copy(scope.position)
// console.log 'origposition', origposition
if (spinnerTimer == null) {
spinnerTimer = setTimeout(function () {
spinner.add(element)
return (spinnerTimer = null)
}, 100)
}
return layoutReady.promise.then(function (parentSize) {
const [h, w] = Array.from(parentSize)
// console.log 'in promise', h, w
return ctrl
.setScale(scale, h, w)
.then(() =>
// console.log 'in setscale then', scale, h, w
scope.$evalAsync(function () {
if (spinnerTimer) {
clearTimeout(spinnerTimer)
} else {
spinner.remove(element)
}
// stop displaying the text layer
element.removeClass('pdfjs-viewer-show-text')
ctrl.redraw(origposition)
$timeout(renderVisiblePages)
return (scope.loadSuccess = true)
})
)
.catch(error => scope.$emit('pdf:error', error))
})
}
let elementTimer = null
function updateLayout() {
// if element is zero-sized keep checking until it is ready
// console.log 'checking element ready', element.height(), element.width()
if (element.height() === 0 || element.width() === 0) {
if (elementTimer != null) {
return
}
return (elementTimer = setTimeout(function () {
elementTimer = null
return updateLayout()
}, 1000))
} else {
scope.parentSize = [element.innerHeight(), element.innerWidth()]
// console.log 'resolving layoutReady with', scope.parentSize
return $timeout(function () {
layoutReady.resolve(scope.parentSize)
return scope.$emit('flash-controls')
})
}
}
let layoutTimer = null
const queueLayout = function () {
// console.log 'call to queue layout'
if (layoutTimer != null) {
return
}
// console.log 'added to queue layoyt'
layoutReady = $q.defer()
return (layoutTimer = setTimeout(function () {
// console.log 'calling update layout'
updateLayout()
// console.log 'setting layout timer to null'
return (layoutTimer = null)
}, 0))
}
queueLayout()
// scope.$on 'layout:pdf:view', (e, args) ->
// console.log 'pdf view change', element, e, args
// queueLayout()
scope.$on('layout:main:resize', () =>
// console.log 'GOT LAYOUT-MAIN-RESIZE EVENT'
queueLayout()
)
scope.$on('layout:pdf:resize', () =>
// FIXME we get this event twice
// also we need to start a new layout when we get it
// console.log 'GOT LAYOUT-PDF-RESIZE EVENT'
queueLayout()
)
scope.$on('pdf:error', function (event, error) {
if (error.name === 'RenderingCancelledException') {
return
}
// check if too many retries or file is missing
const message = (error != null ? error.message : undefined) || error
if (
scope.loadCount > 3 ||
/^Missing PDF/i.test(message) ||
/^loading/i.test(message)
) {
scope.$emit('pdf:error:display')
return
}
if (scope.loadSuccess) {
return ctrl
.load()
.then(
() =>
// trigger a redraw
(scope.scale = angular.copy(scope.scale))
)
.catch(error => scope.$emit('pdf:error:display'))
} else {
scope.$emit('pdf:error:display')
}
})
scope.$on('pdf:page:size-change', function (event, pageNum, delta) {
// console.log 'page size change event', pageNum, delta
const origposition = angular.copy(scope.position)
// console.log 'orig position', JSON.stringify(origposition)
if (
origposition != null &&
pageNum - 1 < origposition.page &&
delta !== 0
) {
const currentScrollTop = element.scrollTop()
// console.log 'adjusting scroll from', currentScrollTop, 'by', delta
scope.adjustingScroll = true
return element.scrollTop(currentScrollTop + delta)
}
})
element.on('mousedown', function (e) {
// We're checking that the event target isn't the directive root element
// to make sure that the click was within a PDF page - no point in showing
// the text layer when the click is outside.
// If the user clicks a PDF page, the mousedown target will be the canvas
// element (or the text layer one). Alternatively, if the event target is
// the root element, we can assume that the user has clicked either the
// grey background area or the scrollbars.
if (e.target !== element[0] && !_hasSelection()) {
element.addClass('pdfjs-viewer-show-text')
return _setMouseUpHandler()
}
})
let mouseUpHandler = null // keep track of the handler to avoid adding multiple times
function _setMouseUpHandler() {
if (mouseUpHandler == null) {
return (mouseUpHandler = $(document.body).one(
'mouseup',
_handleSelectionMouseUp
))
}
}
function _handleSelectionMouseUp() {
mouseUpHandler = null // reset handler, has now fired
window.setTimeout(function () {
const removedClass = _removeClassIfNoSelection()
// if we still have a selection we need to keep the handler going
if (!removedClass) {
return _setMouseUpHandler()
}
}, 10)
return true
}
function _removeClassIfNoSelection() {
if (_hasSelection()) {
return false // didn't remove the text layer
} else {
element.removeClass('pdfjs-viewer-show-text')
return true
}
}
function _hasSelection() {
const selection =
typeof window.getSelection === 'function'
? window.getSelection()
: undefined
// check the selection collapsed state in preference to
// using selection.toString() as the latter is "" when
// the selection is hidden (e.g. while viewing logs)
return (
selection != null &&
_isSelectionWithinPDF(selection) &&
!selection.isCollapsed
)
}
function _isSelectionWithinPDF(selection) {
if (selection.rangeCount === 0) {
return false
}
const selectionAncestorNode = selection.getRangeAt(0)
.commonAncestorContainer
return (
element.find(selectionAncestorNode).length > 0 ||
element.is(selectionAncestorNode)
)
}
element.on('scroll', function () {
// console.log 'scroll event', element.scrollTop(), 'adjusting?', scope.adjustingScroll
// scope.scrollPosition = element.scrollTop()
if (scope.adjustingScroll) {
renderVisiblePages()
scope.adjustingScroll = false
return
}
if (scope.scrollHandlerTimeout) {
clearTimeout(scope.scrollHandlerTimeout)
}
return (scope.scrollHandlerTimeout = setTimeout(scrollHandler, 25))
})
function scrollHandler() {
renderVisiblePages()
const newPosition = ctrl.getPdfPosition()
if (newPosition != null) {
scope.position = newPosition
}
return (scope.scrollHandlerTimeout = null)
}
scope.$watch('pdfSrc', function (newVal, oldVal) {
// console.log 'loading pdf', newVal, oldVal
if (newVal == null) {
return
}
scope.loadCount = 0 // new pdf, so reset load count
scope.loadSuccess = false
return ctrl
.load()
.then(
() =>
// trigger a redraw
(scope.scale = angular.copy(scope.scale))
)
.catch(error => scope.$emit('pdf:error', error))
})
scope.$watch('scale', function (newVal, oldVal) {
// no need to set scale when initialising, done in pdfSrc
if (newVal === oldVal) {
return
}
// console.log 'XXX calling Setscale in scale watch'
return queueRescale(newVal)
})
scope.$watch('forceScale', function (newVal, oldVal) {
// console.log 'got change in numscale watcher', newVal, oldVal
if (newVal == null) {
return
}
return queueRescale(newVal)
})
// scope.$watch 'position', (newVal, oldVal) ->
// console.log 'got change in position watcher', newVal, oldVal
scope.$watch('forceCheck', function (newVal, oldVal) {
// console.log 'forceCheck', newVal, oldVal
if (newVal == null) {
return
}
scope.adjustingScroll = true // temporarily disable scroll
return queueRescale(scope.scale)
})
scope.$watch(
'parentSize',
function (newVal, oldVal) {
// console.log 'XXX in parentSize watch', newVal, oldVal
// if newVal == oldVal
// console.log 'returning because old and new are the same'
// return
// return unless oldVal?
// console.log 'XXX calling setScale in parentSize watcher'
if (newVal == null) {
return
}
return queueRescale(scope.scale)
},
true
)
// scope.$watch 'elementWidth', (newVal, oldVal) ->
// console.log '*** watch INTERVAL element width is', newVal, oldVal
scope.$watch('pleaseScrollTo', function (newVal, oldVal) {
// console.log 'got request to ScrollTo', newVal, 'oldVal', oldVal
if (newVal == null) {
return
}
scope.adjustingScroll = true // temporarily disable scroll
// handler while we reposition
$(element).scrollTop(newVal)
return (scope.pleaseScrollTo = undefined)
})
scope.$watch('pleaseJumpTo', function (newPosition, oldPosition) {
// console.log 'in pleaseJumpTo', newPosition, oldPosition
if (newPosition == null) {
return
}
return ctrl.setPdfPosition(scope.pages[newPosition.page - 1], newPosition)
})
scope.$watch('navigateTo', function (newVal, oldVal) {
if (newVal == null) {
return
}
// console.log 'got request to navigate to', newVal, 'oldVal', oldVal
scope.navigateTo = undefined
// console.log 'navigate to', newVal
// console.log 'look up page num'
return scope.document.getDestination(newVal.dest).then(r =>
// console.log 'need to go to', r
// console.log 'page ref is', r[0]
scope.document.getPageIndex(r[0]).then(function (pidx) {
// console.log 'page num is', pidx
const page = scope.pages[pidx]
return scope.document
.getPdfViewport(page.pageNum)
.then(function (viewport) {
// console.log 'got viewport', viewport
const coords = viewport.convertToViewportPoint(r[2], r[3])
// console.log 'viewport position', coords
// console.log 'r is', r, 'r[1]', r[1], 'r[1].name', r[1].name
if (r[1].name === 'XYZ') {
// console.log 'XYZ:', r[2], r[3]
const newPosition = {
page: pidx,
offset: { top: r[3], left: r[2] },
}
return ctrl.setPdfPosition(scope.pages[pidx], newPosition)
}
})
})
)
}) // XXX?
scope.$watch('highlights', function (areas) {
// console.log 'got HIGHLIGHTS in pdfViewer', areas
if (areas == null) {
return
}
// console.log 'areas are', areas
const highlights = Array.from(areas || []).map(area => ({
page: area.page - 1,
highlight: {
left: area.h,
top: area.v,
height: area.height,
width: area.width,
},
}))
// console.log 'highlights', highlights
if (!highlights.length) {
return
}
scope.$broadcast('pdf:highlights', areas)
const first = highlights[0]
// switching between split and full pdf views can cause
// highlights to appear before rendering
if (!scope.pages) {
return // ignore highlight scroll if still rendering
}
const pageNum =
scope.pages[first.page] != null
? scope.pages[first.page].pageNum
: undefined
if (pageNum == null) {
return // ignore highlight scroll if page not found
}
// use a visual offset of 72pt to match the offset in PdfController syncToCode
return scope.document.getPdfViewport(pageNum).then(function (viewport) {
const position = {
page: first.page,
offset: {
left: first.highlight.left,
top:
viewport.viewBox[3] -
first.highlight.top +
first.highlight.height +
72,
},
}
return ctrl.setPdfPosition(scope.pages[first.page], position)
})
})
return scope.$on('$destroy', function () {
// console.log 'handle pdfng directive destroy'
if (elementTimer != null) {
clearTimeout(elementTimer)
}
if (layoutTimer != null) {
clearTimeout(layoutTimer)
}
if (rescaleTimer != null) {
clearTimeout(rescaleTimer)
}
if (spinnerTimer != null) {
return clearTimeout(spinnerTimer)
}
})
},
}))
function __range__(left, right, inclusive) {
const range = []
const ascending = left < right
const end = !inclusive ? right : ascending ? right + 1 : right - 1
for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
range.push(i)
}
return range
}
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}