@abcnews/components-builder 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,14 @@ The update checker extracts the current version number from the URL, and recursi
16
16
 
17
17
  Please ensure all your application state is kept in the URL, otherwise it may be lost on upgrade.
18
18
 
19
+ Please take care when releasing your projects. If the deploy fails and you skip a version this component will get stuck at that point. You must either rewrite your git history to delete and redeploy the missing version, or add an empty index.html on contentftp to trick the updater into skipping it.
20
+
21
+ To tnclude the update checker, use the following code with optional button text:
22
+
23
+ ```svelte
24
+ <UpdateChecker buttonText="Open new builder" />
25
+ ```
26
+
19
27
  ## ScreenshotTool (fallback images)
20
28
 
21
29
  The screenshot tool allows users to paste an entire article, then extracts all the markers and sends them to a third-party service to screenshot.
@@ -24,6 +32,25 @@ For this to work you must set up a standalone page that can display the visualis
24
32
 
25
33
  Note that the screenshot tool is not fast. We haven't been able to speed up the server side enough to make this a great experience.
26
34
 
35
+ ```svelte
36
+ <ScreenshotTool
37
+ defaultMarkerName={() => "My marker"}
38
+ prefixes={{
39
+ "Scrolly mark": "#mark",
40
+ "Scrolly opener": "#scrollytellerNAMEelectionmap1",
41
+ "Inline graphic": "#graphicinline",
42
+ }}
43
+ iframeUrl={window.location.origin +
44
+ window.location.pathname.replace("/builder/", "/iframe/")}
45
+ />
46
+ ```
47
+
48
+ The `defaultMarkerName` Function provides a user-friendly version of the marker for the preview step. This is the same funcitopn as the `MarkerAdmin` component.
49
+
50
+ Prefixes correlate to the different marker types that have been implemented in the app. This is the same object as the `MarkerAdmin` component.
51
+
52
+ The iframe URL is the location that the screenshot tool hits to create screenshots. The screenshot tool will pass this iframe config via the hash, e.g. /iframe/#MARKER.
53
+
27
54
  ## Typeahead
28
55
 
29
56
  The typeahead component uses a native HTML input element, in conjunction with the datalist element, to provide native searching and keyboard accessibility. The component implements its own multi-select functionality as that isn't available natively.
@@ -95,6 +122,55 @@ This uses the native dialogue element, so focus will always be inside the modal.
95
122
  <Modal title="Example modal" {children} {footerChildren} />
96
123
  ```
97
124
 
125
+ ## Google Doc Scrollyteller
126
+
127
+ This component lets edits draft stories in Google Docs and preview to scroll teller in real time. Period this is useful because multiple people can be editing at once and have a real-time preview, whereas the CMS is single user and can be slow to iterate on.
128
+
129
+ This component must be set up on its own page, as it has two different routes built into it. An example implementation follows:
130
+
131
+ ```svelte
132
+ <script>
133
+ import {
134
+ BuilderStyleRoot,
135
+ GoogleDocScrollyteller,
136
+ } from "@abcnews/components-builder";
137
+ import { loadScrollyteller } from "@abcnews/svelte-scrollyteller";
138
+ import MyScrollyteller from "../components/MyScrollyteller.svelte";
139
+ </script>
140
+
141
+ <BuilderStyleRoot>
142
+ <GoogleDocScrollyteller
143
+ name="electionmap"
144
+ {loadScrollyteller}
145
+ ScrollytellerRoot={MyScrollyteller}
146
+ />
147
+ </BuilderStyleRoot>
148
+ ```
149
+
150
+ You must pass in the loadScrollyteller function, as well as your component that implements svelte-scrollyteller. When the Google doc is loaded, your component will be mounted with the relevant markup in-page.
151
+
152
+ ## MarkerAdmin
153
+
154
+ This is a component that streamlines how you handle markers. It includes a copy and paste function as well as the ability to save and load markers from localstorage.
155
+
156
+ As a prerequisite your builder must store its state in window.location.hash. E.g. `/builder/#marker`.
157
+
158
+ ```svelte
159
+ <MarkerAdmin
160
+ projectName="elections-federal2025-lower-house"
161
+ prefixes={{
162
+ "Scrolly mark": "#mark",
163
+ "Scrolly opener": "#scrollytellerNAMEelectionmap1",
164
+ "Inline graphic": "#graphicinline",
165
+ }}
166
+ defaultMarkerName={() => "My marker"}
167
+ />
168
+ ```
169
+
170
+ `defaultMarkerName` is a function that returns a user friendly name for the marker when the user clicks the save button. You can use this to customise default marker names based on the current hash, which can be useful if you have several different visualisations with distinct names.
171
+
172
+ Prefixes correlate to the different marker types that have been implemented in the app. Users can choose which type of marker they want to copy. This is the same object as the `MarkerAdmin` component.
173
+
98
174
  ## Developing
99
175
 
100
176
  Once you've nstalled dependencies with `npm install`, start a development storybook:
@@ -1,175 +1,177 @@
1
1
  <script lang="ts">
2
- let { children } = $props();
2
+ let { children } = $props();
3
3
  </script>
4
4
 
5
5
  <div class="builder-style-root">
6
- {@render children?.()}
6
+ {@render children?.()}
7
7
  </div>
8
8
 
9
- <style>
10
- .builder-style-root {
11
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
12
- line-height: 1.5;
13
- font-weight: 400;
14
-
15
- --text: #222;
16
- --text-light: #888;
17
- --background: #fff;
18
- --border: rgba(122, 123, 135, 0.5);
19
- --background-alt: #f2f4f5;
20
-
21
- color: var(--text);
22
- background-color: var(--background);
23
-
24
- font-synthesis: none;
25
- text-rendering: optimizeLegibility;
26
- -webkit-font-smoothing: antialiased;
27
- -moz-osx-font-smoothing: grayscale;
28
- -webkit-text-size-adjust: 100%;
29
-
30
- color-scheme: dark light;
31
- :global(*) {
32
- box-sizing: border-box;
33
- }
34
- }
35
-
36
- @media (prefers-color-scheme: dark) {
37
- .builder-style-root {
38
- --text: #ccc;
39
- --text-light: #888;
40
- --background: #1a1a1a;
41
- --background-alt: #2c2c2f;
42
- --border: rgba(122, 123, 135, 0.5);
43
- }
44
- }
45
-
46
- /* // builder-style-root */
47
- .builder-style-root :global {
48
- fieldset {
49
- margin-bottom: 1rem;
50
- padding: var(--padding);
51
- border: 1px solid var(--border);
52
- position: relative;
53
- display: flex;
54
- flex-direction: column;
55
- gap: 0.5rem;
56
- }
57
-
58
- .fieldset {
59
- padding: 0 var(--padding);
60
- border: 1px solid transparent;
61
- }
62
- .fieldset,
63
- fieldset {
64
- --padding: 0.75rem;
65
- margin-bottom: 1rem;
66
- border-radius: 0.2rem;
67
- }
68
- fieldset.builder__spacious,
69
- .fieldset.builder__spacious {
70
- --padding: 1rem;
71
- legend {
72
- font-weight: bold;
73
- }
74
- }
75
-
76
- .builder__inline {
77
- display: flex;
78
- flex-wrap: wrap;
79
- gap: 0.5rem;
80
- }
81
-
82
- .buttons {
83
- display: flex;
84
- flex-wrap: wrap;
85
- gap: 2px;
86
- }
87
-
88
- label {
89
- margin-bottom: 0.5rem;
90
- position: relative;
91
- }
92
- label span {
93
- display: block;
94
- margin-bottom: 0.3rem;
95
- }
96
-
97
- select,
98
- button,
99
- input,
100
- textarea {
101
- padding: 0.25rem 0.5rem;
102
- background: var(--background);
103
- border: 1px solid var(--border);
104
- color: var(--text);
105
- border-radius: 0.2rem;
106
- }
107
- select:not([multiple]),
108
- button:not(:disabled) {
109
- cursor: pointer;
110
- &:hover,
111
- &:focus-visible {
112
- border-color: var(--text);
113
- background: Highlight;
114
- color: HighlightText;
115
- }
116
- }
117
- button:disabled {
118
- color: var(--text-light);
119
- }
120
-
121
- select,
122
- input[type='text'],
123
- input[type='password'],
124
- textarea {
125
- width: 100%;
126
- }
127
-
128
- .btn-icon {
129
- padding: 0;
130
- display: inline-flex;
131
- height: 1.5rem;
132
- width: 1.5rem;
133
- justify-content: center;
134
- align-items: center;
135
- }
136
-
137
- .builder__submit-row {
138
- text-align: right;
139
- border-top: 1px solid var(--border);
140
- padding: 0.5rem 0;
141
- }
142
- hr {
143
- width: 100%;
144
- border: none;
145
- border-bottom: 1px solid var(--border);
146
- }
147
-
148
- table.builder__table {
149
- border-collapse: separate;
150
- border: solid var(--border) 1px;
151
- border-radius: 0.2rem;
152
- border-spacing: 0;
153
-
154
- td,
155
- th {
156
- border-left: solid var(--border) 1px;
157
- border-top: solid var(--border) 1px;
158
- padding: 0.25rem 0.5rem;
159
- }
160
-
161
- th {
162
- background-color: var(--background-alt);
163
- }
164
-
165
- th {
166
- border-top: none;
167
- }
168
-
169
- td:first-child,
170
- th:first-child {
171
- border-left: none;
172
- }
173
- }
174
- }
175
- </style>
9
+ <svelte:head>
10
+ <style>
11
+ .builder-style-root {
12
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
13
+ line-height: 1.5;
14
+ font-weight: 400;
15
+
16
+ --text: #222;
17
+ --text-light: #888;
18
+ --background: #fff;
19
+ --border: rgba(122, 123, 135, 0.5);
20
+ --background-alt: #f2f4f5;
21
+
22
+ color: var(--text);
23
+ background-color: var(--background);
24
+
25
+ font-synthesis: none;
26
+ text-rendering: optimizeLegibility;
27
+ -webkit-font-smoothing: antialiased;
28
+ -moz-osx-font-smoothing: grayscale;
29
+ -webkit-text-size-adjust: 100%;
30
+
31
+ color-scheme: dark light;
32
+ }
33
+
34
+ @media (prefers-color-scheme: dark) {
35
+ .builder-style-root {
36
+ --text: #ccc;
37
+ --text-light: #888;
38
+ --background: #1a1a1a;
39
+ --background-alt: #2c2c2f;
40
+ --border: rgba(122, 123, 135, 0.5);
41
+ }
42
+ }
43
+
44
+ /* // builder-style-root */
45
+ .builder-style-root {
46
+ * {
47
+ box-sizing: border-box;
48
+ }
49
+ fieldset {
50
+ margin-bottom: 1rem;
51
+ padding: var(--padding);
52
+ border: 1px solid var(--border);
53
+ position: relative;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 0.5rem;
57
+ }
58
+
59
+ .fieldset {
60
+ padding: 0 var(--padding);
61
+ border: 1px solid transparent;
62
+ }
63
+ .fieldset,
64
+ fieldset {
65
+ --padding: 0.75rem;
66
+ margin-bottom: 1rem;
67
+ border-radius: 0.2rem;
68
+ }
69
+ fieldset.builder__spacious,
70
+ .fieldset.builder__spacious {
71
+ --padding: 1rem;
72
+ legend {
73
+ font-weight: bold;
74
+ }
75
+ }
76
+
77
+ .builder__inline {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 0.5rem;
81
+ }
82
+
83
+ .buttons {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ gap: 2px;
87
+ }
88
+
89
+ label {
90
+ margin-bottom: 0.5rem;
91
+ position: relative;
92
+ }
93
+ label span {
94
+ display: block;
95
+ margin-bottom: 0.3rem;
96
+ }
97
+
98
+ select,
99
+ button,
100
+ input,
101
+ textarea {
102
+ padding: 0.25rem 0.5rem;
103
+ background: var(--background);
104
+ border: 1px solid var(--border);
105
+ color: var(--text);
106
+ border-radius: 0.2rem;
107
+ }
108
+ select:not([multiple]),
109
+ button:not(:disabled) {
110
+ cursor: pointer;
111
+ &:hover,
112
+ &:focus-visible {
113
+ border-color: var(--text);
114
+ background: Highlight;
115
+ color: HighlightText;
116
+ }
117
+ }
118
+ button:disabled {
119
+ color: var(--text-light);
120
+ }
121
+
122
+ select,
123
+ input[type="text"],
124
+ input[type="password"],
125
+ textarea {
126
+ width: 100%;
127
+ }
128
+
129
+ .btn-icon {
130
+ padding: 0;
131
+ display: inline-flex;
132
+ height: 1.5rem;
133
+ width: 1.5rem;
134
+ justify-content: center;
135
+ align-items: center;
136
+ }
137
+
138
+ .builder__submit-row {
139
+ text-align: right;
140
+ border-top: 1px solid var(--border);
141
+ padding: 0.5rem 0;
142
+ }
143
+ hr {
144
+ width: 100%;
145
+ border: none;
146
+ border-bottom: 1px solid var(--border);
147
+ }
148
+
149
+ table.builder__table {
150
+ border-collapse: separate;
151
+ border: solid var(--border) 1px;
152
+ border-radius: 0.2rem;
153
+ border-spacing: 0;
154
+
155
+ td,
156
+ th {
157
+ border-left: solid var(--border) 1px;
158
+ border-top: solid var(--border) 1px;
159
+ padding: 0.25rem 0.5rem;
160
+ }
161
+
162
+ th {
163
+ background-color: var(--background-alt);
164
+ }
165
+
166
+ th {
167
+ border-top: none;
168
+ }
169
+
170
+ td:first-child,
171
+ th:first-child {
172
+ border-left: none;
173
+ }
174
+ }
175
+ }
176
+ </style>
177
+ </svelte:head>
@@ -20,5 +20,6 @@
20
20
  args={{
21
21
  name: "myscrollyteller",
22
22
  markerName: "mark",
23
+ loadScrollyteller: () => ({}),
23
24
  }}
24
25
  />
@@ -11,10 +11,16 @@
11
11
  name = "myscrollyteller",
12
12
  markerName = "mark",
13
13
  ScrollytellerRoot,
14
+ loadScrollyteller,
14
15
  }: {
15
16
  name: string;
16
- markerName: string;
17
+ markerName?: string;
17
18
  ScrollytellerRoot: Component;
19
+ loadScrollyteller: (
20
+ name?: string,
21
+ className?: string,
22
+ markerName?: string,
23
+ ) => any;
18
24
  } = $props();
19
25
 
20
26
  const localStorageKey = `ABC_NEWS_BUILDER_GDOC_PREVIEW`;
@@ -63,6 +69,7 @@
63
69
  name,
64
70
  url: doc,
65
71
  markerName,
72
+ loadScrollyteller,
66
73
  })
67
74
  .then((data) => {
68
75
  title = data.title;
@@ -1,8 +1,9 @@
1
1
  import type { Component } from "svelte";
2
2
  type $$ComponentProps = {
3
3
  name: string;
4
- markerName: string;
4
+ markerName?: string;
5
5
  ScrollytellerRoot: Component;
6
+ loadScrollyteller: (name?: string, className?: string, markerName?: string) => any;
6
7
  };
7
8
  declare const GoogleDocScrollyteller: Component<$$ComponentProps, {}, "">;
8
9
  type GoogleDocScrollyteller = ReturnType<typeof GoogleDocScrollyteller>;
@@ -8,16 +8,17 @@
8
8
  * @param {function} [postprocessScrollytellerDefinition] -
9
9
  * @returns
10
10
  */
11
- export declare function loadData({ name, className, markerName, url, preprocessCoreEl, postprocessScrollytellerDefinition }: {
11
+ export declare function loadData({ name, className, markerName, url, preprocessCoreEl, postprocessScrollytellerDefinition, loadScrollyteller, }: {
12
12
  name: any;
13
13
  className?: string | undefined;
14
14
  markerName?: string | undefined;
15
15
  url: any;
16
16
  preprocessCoreEl?: ((el: any) => any) | undefined;
17
17
  postprocessScrollytellerDefinition?: ((a: any) => any) | undefined;
18
+ loadScrollyteller: any;
18
19
  }): Promise<{
19
20
  title: string | null | undefined;
20
21
  coreText: string;
21
22
  coreHTML: string;
22
- scrollytellerDefinition: import("@abcnews/svelte-scrollyteller/dist/types").ScrollytellerDefinition;
23
+ scrollytellerDefinition: any;
23
24
  } | undefined>;
@@ -1,9 +1,8 @@
1
- import { loadScrollyteller } from '@abcnews/svelte-scrollyteller';
2
1
  function mountTextToMountEl(mountText) {
3
- const mountEl = document.createElement('div');
4
- mountEl.setAttribute('data-mount', '');
5
- mountEl.setAttribute('data-component', 'Anchor');
6
- mountEl.setAttribute('id', mountText.slice(1));
2
+ const mountEl = document.createElement("div");
3
+ mountEl.setAttribute("data-mount", "");
4
+ mountEl.setAttribute("data-component", "Anchor");
5
+ mountEl.setAttribute("id", mountText.slice(1));
7
6
  return mountEl;
8
7
  }
9
8
  /**
@@ -16,55 +15,56 @@ function mountTextToMountEl(mountText) {
16
15
  * @param {function} [postprocessScrollytellerDefinition] -
17
16
  * @returns
18
17
  */
19
- export async function loadData({ name, className = 'u-full', markerName = 'mark', url, preprocessCoreEl = el => el, postprocessScrollytellerDefinition = a => a }) {
18
+ export async function loadData({ name, className = "u-full", markerName = "mark", url, preprocessCoreEl = (el) => el, postprocessScrollytellerDefinition = (a) => a, loadScrollyteller, }) {
20
19
  if (!url) {
21
20
  return;
22
21
  }
23
- const pubURL = url.replace(/\/[^/]+?$/, '/pub');
24
- const html = await fetch(pubURL).then(response => response.text());
25
- const dom = new DOMParser().parseFromString(html, 'text/html');
26
- const body = dom.querySelector('#contents > div');
27
- const title = dom.querySelector('title')?.textContent;
22
+ const pubURL = url.replace(/\/[^/]+?$/, "/pub");
23
+ const html = await fetch(pubURL).then((response) => response.text());
24
+ const dom = new DOMParser().parseFromString(html, "text/html");
25
+ const body = dom.querySelector("#contents > div");
26
+ const title = dom.querySelector("title")?.textContent;
28
27
  if (!body) {
29
- throw new Error('Body not found');
28
+ throw new Error("Body not found");
30
29
  }
31
- Array.from(body.querySelectorAll('*')).forEach(el => {
32
- el.removeAttribute('class');
33
- el.removeAttribute('id');
30
+ Array.from(body.querySelectorAll("*")).forEach((el) => {
31
+ el.removeAttribute("class");
32
+ el.removeAttribute("id");
34
33
  });
35
34
  const coreEls = Array.from(body.children).map(preprocessCoreEl);
36
35
  const coreText = coreEls.reduce((memo, el) => {
37
36
  const text = String(el.textContent).trim();
38
- memo = `${memo}\n${text ? `\n${text}` : ''}`;
37
+ memo = `${memo}\n${text ? `\n${text}` : ""}`;
39
38
  return memo;
40
- }, '');
39
+ }, "");
41
40
  const coreHTML = coreEls.reduce((memo, el) => {
42
41
  const text = String(el.textContent).trim();
43
42
  const html = el.outerHTML;
44
- memo = `${memo}${text ? `${html}` : ''}`;
43
+ memo = `${memo}${text ? `${html}` : ""}`;
45
44
  return memo;
46
- }, '');
45
+ }, "");
47
46
  const { scrollytellingEls } = coreEls.reduce((memo, el) => {
48
47
  if (memo.hasEnded) {
49
48
  return memo;
50
49
  }
51
50
  const text = String(el.textContent).trim();
52
- if (text.indexOf('#remove') === 0) {
51
+ if (text.indexOf("#remove") === 0) {
53
52
  memo.isRemoving = true;
54
53
  }
55
- else if (text.indexOf('#endremove') === 0) {
54
+ else if (text.indexOf("#endremove") === 0) {
56
55
  memo.isRemoving = false;
57
56
  }
58
- else if (text.indexOf('#') === 0) {
59
- if (text.indexOf(`#scrollyteller${name ? `NAME${name}` : ''}`) === 0 && !memo.hasBegun) {
57
+ else if (text.indexOf("#") === 0) {
58
+ if (text.indexOf(`#scrollyteller${name ? `NAME${name}` : ""}`) === 0 &&
59
+ !memo.hasBegun) {
60
60
  memo.hasBegun = true;
61
61
  }
62
- else if (text.indexOf('#endscrollyteller') === 0) {
62
+ else if (text.indexOf("#endscrollyteller") === 0) {
63
63
  memo.hasEnded = true;
64
64
  }
65
65
  memo.scrollytellingEls.push(mountTextToMountEl(text));
66
66
  }
67
- else if (!memo.hasBegun || memo.isRemoving || text === '') {
67
+ else if (!memo.hasBegun || memo.isRemoving || text === "") {
68
68
  // skip
69
69
  }
70
70
  else {
@@ -75,10 +75,10 @@ export async function loadData({ name, className = 'u-full', markerName = 'mark'
75
75
  hasBegun: false,
76
76
  hasEnded: false,
77
77
  isRemoving: false,
78
- scrollytellingEls: []
78
+ scrollytellingEls: [],
79
79
  });
80
- const container = document.createElement('div');
81
- scrollytellingEls.forEach(scrollytellingEl => container.appendChild(scrollytellingEl));
80
+ const container = document.createElement("div");
81
+ scrollytellingEls.forEach((scrollytellingEl) => container.appendChild(scrollytellingEl));
82
82
  document.body.appendChild(container);
83
83
  let scrollytellerDefinition = loadScrollyteller(name, className, markerName);
84
84
  document.body.removeChild(container);
@@ -89,6 +89,6 @@ export async function loadData({ name, className = 'u-full', markerName = 'mark'
89
89
  title,
90
90
  coreText,
91
91
  coreHTML,
92
- scrollytellerDefinition
92
+ scrollytellerDefinition,
93
93
  };
94
94
  }
@@ -0,0 +1,417 @@
1
+ <script lang="ts">
2
+ import { onMount, untrack } from "svelte";
3
+ import { slide } from "svelte/transition";
4
+
5
+ let {
6
+ /** Unique project name used in localStorage (e.g. "elections-federal2025-lower-house")*/
7
+ projectName = "dev",
8
+ defaultMarkerName = () => "",
9
+ prefixes = {
10
+ "Scrolly mark": "#mark",
11
+ "Scrolly opener": "#scrollytellerNAMEscrolly1",
12
+ "Standalone graphic": "#graphic",
13
+ },
14
+ } = $props();
15
+
16
+ type Marker = {
17
+ /** human readable name */
18
+ name: string;
19
+ /** actual marker string */
20
+ hash: string;
21
+ /** Date at which the marker was deleted (for undo) */
22
+ deleted?: number;
23
+ };
24
+
25
+ let hasLoaded = $state(false);
26
+ let markers = $state<Marker[]>([]);
27
+ let mode = $state(Object.keys(prefixes)?.[0]);
28
+
29
+ /** which button should show the success indicator */
30
+ let successIndicator = $state("");
31
+
32
+ const [version = "0.0.0"] =
33
+ window.location.pathname
34
+ .match(/\/news-projects\/[^/]+\/(\d+\.\d+\.\d+)/)
35
+ ?.slice(1) || [];
36
+ const localStorageKey = `ABC_NEWS_BUILDER_${projectName.toUpperCase().replace(/-/g, "_")}`;
37
+
38
+ /** Load & sanitise markers from localStorage on initial load */
39
+ $effect(() => {
40
+ let _markers = markers;
41
+ if (!hasLoaded) {
42
+ return;
43
+ }
44
+ try {
45
+ localStorage[localStorageKey] = JSON.stringify({
46
+ version: version,
47
+ lastUpdated: new Date().toISOString(),
48
+ expiry: 2026,
49
+ markers: _markers,
50
+ });
51
+ } catch (e) {
52
+ alert("Could not save markers: " + (e as Error).message);
53
+ }
54
+ });
55
+
56
+ /* ---------------------------------------------------------------------------
57
+ * slightly more complex delete logic than I hoped. When an item is marked
58
+ * as deleted, set a timer and actually delete it after ~5sec.
59
+ * don't delete while the user is still hovering the items.
60
+ */
61
+ let isHovering = $state(false);
62
+ let interval = $state<NodeJS.Timeout>();
63
+ function clearDeleteCleanup() {
64
+ const _interval = untrack(() => interval);
65
+ if (_interval) {
66
+ clearInterval(_interval);
67
+ interval = undefined;
68
+ }
69
+ }
70
+ $effect(() => {
71
+ const _markers = untrack(() => markers);
72
+
73
+ clearDeleteCleanup();
74
+ if (isHovering) {
75
+ return;
76
+ }
77
+
78
+ interval = setInterval(() => {
79
+ const goodMarkers = (marker) =>
80
+ !marker.deleted || marker.deleted > Date.now() - 5000;
81
+ if (!_markers.every(goodMarkers)) {
82
+ markers = _markers.filter(goodMarkers);
83
+ }
84
+ }, 3000);
85
+ });
86
+
87
+ onMount(function load() {
88
+ try {
89
+ const storage = localStorage[localStorageKey];
90
+ if (!storage) {
91
+ hasLoaded = true;
92
+ return;
93
+ }
94
+ const parsedStorage = JSON.parse(storage);
95
+ markers = parsedStorage.markers;
96
+ } catch (e) {
97
+ alert("Could not load saved markers: " + (e as Error).message);
98
+ }
99
+ hasLoaded = true;
100
+
101
+ return () => {
102
+ clearDeleteCleanup();
103
+ };
104
+ });
105
+ </script>
106
+
107
+ {#snippet saveIcon()}
108
+ <svg
109
+ xmlns="http://www.w3.org/2000/svg"
110
+ width="16"
111
+ height="16"
112
+ fill="currentColor"
113
+ class="bi bi-floppy"
114
+ viewBox="0 0 16 16"
115
+ >
116
+ <title>Save</title>
117
+ <path d="M11 2H9v3h2z" />
118
+ <path
119
+ d="M1.5 0h11.586a1.5 1.5 0 0 1 1.06.44l1.415 1.414A1.5 1.5 0 0 1 16 2.914V14.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 14.5v-13A1.5 1.5 0 0 1 1.5 0M1 1.5v13a.5.5 0 0 0 .5.5H2v-4.5A1.5 1.5 0 0 1 3.5 9h9a1.5 1.5 0 0 1 1.5 1.5V15h.5a.5.5 0 0 0 .5-.5V2.914a.5.5 0 0 0-.146-.353l-1.415-1.415A.5.5 0 0 0 13.086 1H13v4.5A1.5 1.5 0 0 1 11.5 7h-7A1.5 1.5 0 0 1 3 5.5V1H1.5a.5.5 0 0 0-.5.5m3 4a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5V1H4zM3 15h10v-4.5a.5.5 0 0 0-.5-.5h-9a.5.5 0 0 0-.5.5z"
120
+ />
121
+ </svg>
122
+ {/snippet}
123
+
124
+ {#snippet copyIcon()}
125
+ <svg
126
+ xmlns="http://www.w3.org/2000/svg"
127
+ width="16"
128
+ height="16"
129
+ fill="currentColor"
130
+ class="bi bi-copy"
131
+ viewBox="0 0 16 16"
132
+ >
133
+ <title>Copy to clipboard</title>
134
+ <path
135
+ fill-rule="evenodd"
136
+ d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"
137
+ />
138
+ </svg>
139
+ {/snippet}
140
+
141
+ {#snippet pasteIcon()}
142
+ <svg
143
+ xmlns="http://www.w3.org/2000/svg"
144
+ width="16"
145
+ height="16"
146
+ fill="currentColor"
147
+ class="bi bi-clipboard"
148
+ viewBox="0 0 16 16"
149
+ >
150
+ <title>Paste marker from clipboard</title>
151
+ <path
152
+ d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"
153
+ />
154
+ <path
155
+ d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"
156
+ />
157
+ </svg>
158
+ {/snippet}
159
+
160
+ {#snippet deleteIcon()}
161
+ <svg
162
+ xmlns="http://www.w3.org/2000/svg"
163
+ width="16"
164
+ height="16"
165
+ fill="currentColor"
166
+ class="bi bi-trash"
167
+ viewBox="0 0 16 16"
168
+ >
169
+ <path
170
+ d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"
171
+ />
172
+ <path
173
+ d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"
174
+ />
175
+ </svg>
176
+ {/snippet}
177
+
178
+ {#snippet undeleteIcon()}
179
+ <svg
180
+ xmlns="http://www.w3.org/2000/svg"
181
+ width="16"
182
+ height="16"
183
+ fill="currentColor"
184
+ class="bi bi-arrow-counterclockwise"
185
+ viewBox="0 0 16 16"
186
+ >
187
+ <path
188
+ fill-rule="evenodd"
189
+ d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z"
190
+ />
191
+ <path
192
+ d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466"
193
+ />
194
+ </svg>
195
+ {/snippet}
196
+
197
+ <div class="toolbar">
198
+ <select bind:value={mode}>
199
+ {#each Object.keys(prefixes) as prefix}
200
+ <option>{prefix}</option>
201
+ {/each}
202
+ </select>
203
+ <button
204
+ title="Copy marker"
205
+ class:success={successIndicator === "copy"}
206
+ onanimationend={() => {
207
+ successIndicator = "";
208
+ }}
209
+ onclick={(e) => {
210
+ e.preventDefault();
211
+ const hash = window.location.hash.slice(1);
212
+ navigator.clipboard.writeText(prefixes[mode] + hash);
213
+ successIndicator = "copy";
214
+ }}
215
+ >
216
+ {@render copyIcon()}
217
+ </button>
218
+ <button
219
+ title="Paste marker from clipboard"
220
+ class:success={successIndicator === "paste"}
221
+ onanimationend={() => {
222
+ successIndicator = "";
223
+ }}
224
+ onclick={async (e) => {
225
+ e.preventDefault();
226
+
227
+ let text: string | null = await navigator.clipboard
228
+ .readText()
229
+ .catch((e) => {
230
+ console.error("Could not read clipboard");
231
+ return null;
232
+ });
233
+
234
+ if (!text) {
235
+ text = prompt("Paste a marker here to import its configuration");
236
+ }
237
+
238
+ if (!text) {
239
+ return;
240
+ }
241
+
242
+ let sanitisedMarker = text;
243
+ Object.keys(prefixes).forEach(
244
+ (prefix) => (sanitisedMarker = sanitisedMarker.replace(prefix, "")),
245
+ );
246
+
247
+ if (window.location.hash.slice(1) === sanitisedMarker) {
248
+ alert(
249
+ "Pasted marker is the same as the current state. No changes were necessary.",
250
+ );
251
+ return;
252
+ }
253
+
254
+ window.location.hash = sanitisedMarker;
255
+
256
+ successIndicator = "paste";
257
+ }}
258
+ >
259
+ {@render pasteIcon()}
260
+ </button>
261
+ <div class="divider"></div>
262
+ <button
263
+ title="Save marker snapshot"
264
+ onclick={(e) => {
265
+ e.preventDefault();
266
+ const name = prompt(
267
+ "What would you like to call this snapshot?",
268
+ defaultMarkerName(),
269
+ );
270
+ if (!name) {
271
+ return;
272
+ }
273
+ markers.push({
274
+ name,
275
+ hash: window.location.hash.slice(1),
276
+ });
277
+ }}
278
+ >
279
+ {@render saveIcon()}
280
+ </button>
281
+ </div>
282
+
283
+ <svelte:window
284
+ onblur={() => {
285
+ isHovering = false;
286
+ }}
287
+ />
288
+
289
+ <table
290
+ class="saved-markers"
291
+ onmouseenter={() => {
292
+ isHovering = true;
293
+ }}
294
+ onmouseleave={() => {
295
+ isHovering = false;
296
+ }}
297
+ >
298
+ <tbody>
299
+ {#each markers as marker, index}
300
+ <tr
301
+ class="row"
302
+ class:row--deleted={marker.deleted}
303
+ transition:slide
304
+ onclick={(e) =>
305
+ //@ts-ignore
306
+ e.target?.querySelector("a")?.click()}
307
+ >
308
+ <td class="name">
309
+ {#if marker.deleted}
310
+ {marker.name}
311
+ {:else}
312
+ <a href={`#${marker.hash}`}>
313
+ {marker.name}
314
+ </a>
315
+ {/if}
316
+ </td>
317
+ <td class="buttons">
318
+ <button
319
+ onclick={(e) => {
320
+ e.preventDefault();
321
+ const newMarkers = [...markers];
322
+ const marker = newMarkers[index];
323
+ if (marker.deleted) {
324
+ delete marker.deleted;
325
+ } else {
326
+ marker.deleted = Date.now();
327
+ }
328
+ }}
329
+ title={marker.deleted ? "Undo delete" : "Delete marker"}
330
+ style:height="32px"
331
+ >
332
+ {#if marker.deleted}
333
+ {@render undeleteIcon()}
334
+ {:else}
335
+ {@render deleteIcon()}
336
+ {/if}
337
+ </button>
338
+ </td>
339
+ </tr>
340
+ {/each}
341
+ </tbody>
342
+ </table>
343
+
344
+ <style>
345
+ .toolbar {
346
+ display: flex;
347
+ justify-content: space-between;
348
+ gap: 0.5rem;
349
+
350
+ padding-bottom: 0.5rem;
351
+ border-bottom: 1px solid var(--border);
352
+
353
+ .divider {
354
+ border-right: 1px solid var(--border);
355
+ }
356
+
357
+ button.success {
358
+ animation: success 1s;
359
+ }
360
+ }
361
+
362
+ @keyframes success {
363
+ from {
364
+ outline: 0px solid rgb(114, 191, 114);
365
+ }
366
+ to {
367
+ outline: 10px solid rgb(114, 191, 114, 0);
368
+ }
369
+ }
370
+
371
+ .saved-markers {
372
+ &,
373
+ tr,
374
+ td,
375
+ tbody {
376
+ margin: 0;
377
+ padding: 0;
378
+ display: block;
379
+ }
380
+ tbody {
381
+ width: 100%;
382
+ }
383
+ .row {
384
+ display: flex;
385
+ width: 100%;
386
+ border-bottom: 1px solid var(--border);
387
+ width: calc(100% + 1rem);
388
+ margin-left: -0.5rem;
389
+ border-radius: 1px;
390
+ & > * {
391
+ padding: 0.5rem;
392
+ }
393
+ &:not(.row--deleted):hover {
394
+ cursor: pointer;
395
+ background: Highlight;
396
+ color: HighlightText;
397
+ }
398
+ }
399
+ .row a {
400
+ text-decoration: none;
401
+ color: inherit;
402
+ &:focus-visible {
403
+ text-decoration: underline;
404
+ }
405
+ }
406
+ .name {
407
+ flex: 1;
408
+ display: flex;
409
+ align-items: center;
410
+ transition: all 0.2s;
411
+ }
412
+
413
+ .row--deleted .name {
414
+ opacity: 0.2;
415
+ }
416
+ }
417
+ </style>
@@ -0,0 +1,7 @@
1
+ declare const MarkerAdmin: import("svelte").Component<{
2
+ projectName?: string;
3
+ defaultMarkerName?: Function;
4
+ prefixes?: Record<string, any>;
5
+ }, {}, "">;
6
+ type MarkerAdmin = ReturnType<typeof MarkerAdmin>;
7
+ export default MarkerAdmin;
@@ -12,7 +12,8 @@
12
12
  let {
13
13
  defaultMarkerName = () => "Marker",
14
14
  prefixes = {},
15
- parseMarker = (str) => ({}),
15
+ // Optional function to process markers
16
+ onMarker = (str) => str,
16
17
  iframeUrl = "",
17
18
  } = $props();
18
19
 
@@ -46,11 +47,8 @@
46
47
 
47
48
  const uniqueMarkers = Array.from(new Set(markers));
48
49
 
49
- // parse to object
50
- const encodedMarkers = uniqueMarkers.map((marker) => parse(marker));
51
-
52
50
  // pass through schema
53
- const parsedMarkers = await Promise.all(encodedMarkers.map(parseMarker));
51
+ const parsedMarkers = await Promise.all(uniqueMarkers.map(onMarker));
54
52
 
55
53
  // generate a friendly name & reencode markers with hexFlip="none"
56
54
  preview = await Promise.all(
@@ -6,12 +6,12 @@ type ScreenshotTool = {
6
6
  declare const ScreenshotTool: import("svelte").Component<{
7
7
  defaultMarkerName?: Function;
8
8
  prefixes?: Record<string, any>;
9
- parseMarker?: Function;
9
+ onMarker?: Function;
10
10
  iframeUrl?: string;
11
11
  }, {}, "">;
12
12
  type $$ComponentProps = {
13
13
  defaultMarkerName?: Function;
14
14
  prefixes?: Record<string, any>;
15
- parseMarker?: Function;
15
+ onMarker?: Function;
16
16
  iframeUrl?: string;
17
17
  };
@@ -10,9 +10,10 @@
10
10
  let {
11
11
  overrideNewVersion,
12
12
  buttonText = "Open new builder",
13
- }: { overrideNewVersion: NewVersion; buttonText: string } = $props();
13
+ }: { overrideNewVersion?: NewVersion; buttonText?: string } = $props();
14
14
 
15
- let newVersion = $state<NewVersion>(overrideNewVersion);
15
+ let newVersion = $state<NewVersion | undefined>(overrideNewVersion);
16
+ // svelte-ignore state_referenced_locally
16
17
  let isOpen = $state(!!newVersion);
17
18
 
18
19
  /**
@@ -3,8 +3,8 @@ type NewVersion = {
3
3
  thisVersion: string;
4
4
  };
5
5
  type $$ComponentProps = {
6
- overrideNewVersion: NewVersion;
7
- buttonText: string;
6
+ overrideNewVersion?: NewVersion;
7
+ buttonText?: string;
8
8
  };
9
9
  declare const UpdateChecker: import("svelte").Component<$$ComponentProps, {}, "">;
10
10
  type UpdateChecker = ReturnType<typeof UpdateChecker>;
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export { default as Modal } from "./Modal/Modal.svelte";
5
5
  export { default as ScreenshotTool } from "./ScreenshotTool/ScreenshotTool.svelte";
6
6
  export { default as Typeahead } from "./Typeahead/Typeahead.svelte";
7
7
  export { default as UpdateChecker } from "./UpdateChecker/UpdateChecker.svelte";
8
+ export { default as MarkerAdmin } from "./MarkerAdmin/MarkerAdmin.svelte";
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export { default as Modal } from "./Modal/Modal.svelte";
6
6
  export { default as ScreenshotTool } from "./ScreenshotTool/ScreenshotTool.svelte";
7
7
  export { default as Typeahead } from "./Typeahead/Typeahead.svelte";
8
8
  export { default as UpdateChecker } from "./UpdateChecker/UpdateChecker.svelte";
9
+ export { default as MarkerAdmin } from "./MarkerAdmin/MarkerAdmin.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abcnews/components-builder",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",