Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: Add a Text Caption to a Photo
excerpt: An App Extension that lets field users upload a photo, type a caption, and render the captioned image on an HTML5 canvas — useful for annotating site photos, adding measurement labels, or branding field images before submission.
---

This App Extension uses the browser's HTML5 Canvas API to composite a text caption over a user-selected photo entirely in the browser, with no server round-trip. The result can be screenshotted or extended to upload the annotated image back to Fulcrum.

## App Extension Code

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Photo Caption Tool</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 16px;
max-width: 800px;
margin: 0 auto;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
input[type="file"] { flex: 1 1 180px; }
input[type="text"] { flex: 2 1 240px; padding: 6px; font-size: 14px; }
input[type="range"] { flex: 1 1 120px; }
button {
padding: 8px 16px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover { background: #0055aa; }
canvas {
max-width: 100%;
border: 1px solid #ccc;
display: block;
}
label { font-size: 13px; color: #555; }
</style>
</head>
<body>

<div class="controls">
<input type="file" id="upload" accept="image/*" />
<input type="text" id="captionInput" placeholder="Enter caption text" />
<div>
<label>Font size: <span id="sizeLabel">30</span>px</label>
<input type="range" id="fontSize" min="10" max="80" value="30"
oninput="document.getElementById('sizeLabel').textContent = this.value" />
</div>
<button id="addCaptionButton">Add Caption</button>
</div>

<canvas id="captionCanvas"></canvas>

<script>
const canvas = document.getElementById('captionCanvas');
const ctx = canvas.getContext('2d');
const upload = document.getElementById('upload');
const input = document.getElementById('captionInput');
const button = document.getElementById('addCaptionButton');
const sizeEl = document.getElementById('fontSize');

let img = new Image();

// Load photo to canvas when a file is selected
upload.addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => { img.src = e.target.result; };
reader.readAsDataURL(file);
});

// Draw image to canvas once loaded
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
};

// Composite the caption over the image on button click
button.addEventListener('click', () => {
const text = input.value.trim();
const fontSize = parseInt(sizeEl.value, 10);

if (!text || !img.src) return;

// Redraw the original image (clears any previous caption attempt)
ctx.drawImage(img, 0, 0);

// Position caption centered near the bottom of the image
const x = canvas.width / 2;
const y = canvas.height - fontSize * 1.5;

ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.strokeStyle = 'black';
ctx.lineWidth = fontSize / 8;
ctx.fillStyle = 'white';

// Draw stroke first for a legible outline, then fill
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
});
</script>
</body>
</html>
```

## How It Works

`FileReader.readAsDataURL()` reads the selected image file into a base64 data URL, which is then assigned to an `Image` object's `src`. Once the image fires its `onload` event, the canvas is resized to match the image dimensions and `drawImage()` renders it.

When the user clicks "Add Caption", the canvas is redrawn (to reset any previous caption), and the caption text is drawn at the horizontal center, positioned near the bottom of the image. The text is rendered twice: first with a thick black stroke for contrast, then with a white fill for legibility.

## Customization

**Caption position:** Change the `y` calculation to place the caption at the top (`y = fontSize * 1.5`) or at a fixed pixel offset from any edge.

**Font and color:** Swap `ctx.font` and `ctx.fillStyle` for different typefaces, weights, or colors. Use `ctx.fillStyle = 'rgba(0,0,0,0.5)'` to draw a semi-transparent background bar behind the text.

**Download the captioned image:** Add a download button that calls `canvas.toDataURL('image/jpeg')` and triggers a link click:

```javascript
document.getElementById('downloadBtn').addEventListener('click', () => {
const link = document.createElement('a');
link.download = 'captioned-photo.jpg';
link.href = canvas.toDataURL('image/jpeg', 0.92);
link.click();
});
```

**Multiple captions / drag and drop:** Track caption objects in an array and redraw them all on each render pass to support repositionable annotations.
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
---
title: Date picker with blackout dates
excerpt: >-
This App Extension opens a popup calendar that prevents users from selecting
blacked-out date ranges. Blackout dates are loaded dynamically from a
separate Fulcrum app using LOADRECORDS, so administrators can manage blocked
dates without touching the data event code.
deprecated: false
hidden: false
metadata:
title: ''
description: ''
robots: noindex
next:
description: ''
---

# Date picker with blackout dates

This example uses an App Extension to display a Flatpickr-powered calendar popup that restricts users from selecting certain dates. Blackout date ranges are stored in a separate Fulcrum app and loaded at runtime via `LOADRECORDS`, so administrators can add or remove blocked dates without updating the Data Event.

When the user clicks a button on the form, `OPENEXTENSION` launches the calendar. The selected date is returned to the form and written to a date field.

## How it works

1. A separate **blackout dates app** stores date range records (start date / end date).
2. On `load-record`, the Data Event fetches those ranges via `LOADRECORDS` and stores them in a variable.
3. When the user clicks the calendar button, `OPENEXTENSION` opens `calendar_picker.html` — a self-contained HTML page that uses [Flatpickr](https://flatpickr.js.org/) to render the calendar.
4. The blackout ranges are passed to the HTML page via the `data` option. Flatpickr disables those date ranges in the calendar.
5. When the user picks a date, the HTML page sends it back via `Fulcrum.finish()`, and the Data Event writes it to an appointment date field.

## Setup

1. Create a **blackout dates app** with two Date fields:
- `start` — the first day of the blocked range
- `end` — the last day of the blocked range (inclusive)
Note the **App ID** and the field keys for `start` and `end`.
2. Upload `calendar_picker.html` (below) as a **Reference File** in your Fulcrum org, or attach it directly to your app.
3. In your data collection app, add:
- A **Date** field for the appointment result (e.g. data name: `appointment_date`)
- A **Button** field to trigger the calendar (e.g. data name: `open_calendar`)
4. Add the Data Event code below to the app and update the configuration constants.

> **Note:** The Flatpickr calendar requires an internet connection when using the CDN version below. For fully offline use, replace the CDN links with a locally hosted or embedded copy of Flatpickr.

## Data Event Code

```js
// ─── Configuration ───────────────────────────────────────────────────────────

// App ID of the blackout dates app
const BLACKOUT_FORM_ID = 'YOUR-BLACKOUT-DATES-APP-ID-HERE';

// Field key of the start date field in the blackout app
const START_DATE_KEY = 'start';

// Field key of the end date field in the blackout app
const END_DATE_KEY = 'end';
Comment on lines +55 to +58
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants are described as 'field key', but the sample values ('start', 'end') look like data_names. Since the code reads rec.form_values[START_DATE_KEY], this will only work if those strings are actual form-value keys. Either change the documentation to say these are data_names and read values accordingly, or update the placeholders to look like real Fulcrum field keys and keep the current access pattern.

Suggested change
const START_DATE_KEY = 'start';
// Field key of the end date field in the blackout app
const END_DATE_KEY = 'end';
const START_DATE_KEY = 'YOUR-START-DATE-FIELD-KEY';
// Field key of the end date field in the blackout app
const END_DATE_KEY = 'YOUR-END-DATE-FIELD-KEY';

Copilot uses AI. Check for mistakes.

// Data name of the date field to write the selected appointment date to
const APPOINTMENT_FIELD = 'appointment_date';

// Data name of the button field that opens the calendar
const CALENDAR_BUTTON = 'open_calendar';

// ─── Load blackout ranges on record open ─────────────────────────────────────

let blackoutRanges = [];

ON('load-record', () => {
LOADRECORDS({ form_id: BLACKOUT_FORM_ID }, (err, result) => {
if (err) {
console.log('Error loading blackout dates:', INSPECT(err));
return;
}

// Build an array of { start, end } objects from the loaded records
blackoutRanges = (result.records || []).map(rec => ({
start: rec.form_values[START_DATE_KEY] || '',
end: rec.form_values[END_DATE_KEY] || ''
})).filter(range => range.start && range.end);
});
});

// ─── Open the calendar extension ─────────────────────────────────────────────

ON('click', CALENDAR_BUTTON, () => {
if (!blackoutRanges.length) {
// Allow the calendar to open even if no blackout dates loaded yet
console.log('No blackout ranges loaded; opening calendar without restrictions.');
}

OPENEXTENSION({
url: 'attachment://calendar_picker.html',
title: 'Select Appointment Date',
width: 400,
height: 500,
data: {
blackoutRanges: blackoutRanges,
today: new Date().toISOString().split('T')[0]
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using toISOString().split('T')[0] produces a UTC date string, which can be off by one day for users in negative time zones (and can misalign minDate). Prefer constructing YYYY-MM-DD from the local date (or pass a timestamp and format it consistently in the extension).

Suggested change
today: new Date().toISOString().split('T')[0]
today: (function (d) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
})(new Date())

Copilot uses AI. Check for mistakes.
},
onMessage: ({ data }) => {
if (data.selectedDate) {
SETVALUE(APPOINTMENT_FIELD, data.selectedDate);
}
}
});
});
```

## HTML Extension File (`calendar_picker.html`)

Save the content below as `calendar_picker.html` and attach it to your Fulcrum app as a Reference File. This file is loaded inside the `OPENEXTENSION` popup.

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Appointment Calendar</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
text-align: center;
padding: 20px;
background-color: #f9f9f9;
}
h2 { margin-bottom: 10px; }
#calendar { margin: 0 auto; max-width: 340px; }
.flatpickr-calendar { margin: 0 auto; font-size: 16px !important; }
button {
display: inline-block;
padding: 10px 18px;
margin-top: 20px;
font-size: 16px;
border-radius: 6px;
border: none;
background: #007aff;
color: white;
cursor: pointer;
}
button:hover { background: #005fcc; }
</style>

<!-- Flatpickr date picker library (requires internet connection) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>

<!-- Fulcrum App Extension communication bridge -->
<script>
(()=>{var s=(e,i)=>()=>(i||e((i={exports:{}}).exports,i),i.exports);
var o=s((a,r)=>{var l=new URLSearchParams(location.search);
function c(e){try{return JSON.parse(e)}catch(i){return null}}
r.exports=window.Fulcrum={isExtension:l.get("extension")==="1",
initialize:()=>{var i;let{params:e}=Fulcrum;Fulcrum.id=e?.id,Fulcrum.url=e?.url,Fulcrum.data=e?.data,Fulcrum.origin=e?.origin,(i=Fulcrum.onLoadOnce)?.call(Fulcrum)},
load:e=>{Fulcrum.onLoadOnce=()=>{Fulcrum.params&&!Fulcrum.isLoaded&&(Fulcrum.isLoaded=!0,e({data:Fulcrum.data}))},Fulcrum.onLoadOnce()},
send:(e,{close:i=!1}={})=>{var u;e=e||{};let n={id:Fulcrum.id,url:Fulcrum.url,data:e,close:i};
(u=window.webkit)?.messageHandlers?window.webkit.messageHandlers.extensionListener.postMessage(JSON.stringify(n)):
window.parent&&window.parent.postMessage({extensionMessage:n},Fulcrum.origin)},
receive:e=>{let i=c(e.data);i&&i.command==="initialize"&&!Fulcrum.params&&(Fulcrum.params=i.params,Fulcrum.initialize())},
finish:e=>{Fulcrum.send(e,{close:!0})}};Fulcrum.isExtension?
window.addEventListener("message",Fulcrum.receive,!1):window.addEventListener("DOMContentLoaded",Fulcrum.initialize)});o();})();
</script>
</head>

<body>
<h2>Select Appointment Date</h2>
<div id="calendar"></div>
<button id="cancel">Cancel</button>

<script>
Fulcrum.load(({ data }) => {
const blackoutRanges = data.blackoutRanges || [];

// Convert blackout ranges to the format Flatpickr expects for disabled dates
// Each range disables all dates from start through end (inclusive)
const disabledDates = blackoutRanges.map(r => {
const start = new Date(r.start);
const end = new Date(r.end);

start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
end.setDate(end.getDate() + 1); // include the full end day
Comment on lines +183 to +184
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic likely disables one extra day beyond the intended end date. You already set end to the end of the end-day (23:59:59.999), so adding +1 day extends the disabled range. Remove the extra day bump (or keep the bump but set the end time to the start of day) so the disabled window matches the inclusive end date described.

Suggested change
end.setHours(23, 59, 59, 999);
end.setDate(end.getDate() + 1); // include the full end day
end.setHours(23, 59, 59, 999); // include the full end day

Copilot uses AI. Check for mistakes.

return { from: start, to: end };
});

flatpickr('#calendar', {
inline: true,
minDate: data.today, // Prevent selecting dates in the past
disable: disabledDates, // Grey out the blackout date ranges
dateFormat: 'Y-m-d', // ISO format compatible with Fulcrum date fields
onChange: (selectedDates) => {
if (selectedDates[0]) {
const selected = selectedDates[0].toISOString().split('T')[0];
// Send the selected date back to the Data Event and close the extension
Fulcrum.finish({ selectedDate: selected });
}
}
});

// Cancel button closes the extension without setting a date
document.getElementById('cancel').addEventListener('click', () => {
Fulcrum.finish({});
});
});
</script>
</body>
</html>
```

## Notes

- The blackout date ranges are fetched each time the record is opened. If the blackout app is updated while a user has the form open, they will need to close and reopen the record to get the latest dates.
- Flatpickr's `disable` option accepts an array of `{ from, to }` objects. Dates within those ranges are greyed out and unselectable in the calendar UI.
- The `minDate: data.today` setting prevents users from selecting any date in the past.
- The `Fulcrum.finish({ selectedDate })` call sends the result back to the Data Event's `onMessage` handler and closes the extension popup.
Loading