diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 5755e64616..0bfb98db84 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -48,6 +48,8 @@ let DOM = { isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null }, + isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") }, + findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) }, findComponentNodeList(node, cid){ diff --git a/assets/js/phoenix_live_view/live_uploader.js b/assets/js/phoenix_live_view/live_uploader.js index 080a7481c6..55e3d02d14 100644 --- a/assets/js/phoenix_live_view/live_uploader.js +++ b/assets/js/phoenix_live_view/live_uploader.js @@ -120,6 +120,7 @@ export default class LiveUploader { }) let groupedEntries = this._entries.reduce((acc, entry) => { + if(!entry.meta){ return acc } let {name, callback} = entry.uploader(liveSocket.uploaders) acc[name] = acc[name] || {callback: callback, entries: []} acc[name].entries.push(entry) diff --git a/assets/js/phoenix_live_view/upload_entry.js b/assets/js/phoenix_live_view/upload_entry.js index 9129c9b1b0..1429c4c891 100644 --- a/assets/js/phoenix_live_view/upload_entry.js +++ b/assets/js/phoenix_live_view/upload_entry.js @@ -10,6 +10,7 @@ import { } from "./utils" import LiveUploader from "./live_uploader" +import DOM from "./dom" export default class UploadEntry { static isActive(fileEl, file){ @@ -71,7 +72,7 @@ export default class UploadEntry { error(reason = "failed"){ this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) this.view.pushFileProgress(this.fileEl, this.ref, {error: reason}) - LiveUploader.clearFiles(this.fileEl) + if(!DOM.isAutoUpload(this.fileEl)){ LiveUploader.clearFiles(this.fileEl) } } //private diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index b21fa2e30d..14119514d5 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -911,7 +911,7 @@ export default class View { } this.pushWithReply(refGenerator, "event", event, resp => { DOM.showError(inputEl, this.liveSocket.binding(PHX_FEEDBACK_FOR)) - if(DOM.isUploadInput(inputEl) && inputEl.getAttribute("data-phx-auto-upload") !== null){ + if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){ if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){ let [ref, _els] = refGenerator() this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => { @@ -1073,7 +1073,10 @@ export default class View { let inputs = Array.from(form.elements).filter(el => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange)) if(inputs.length === 0){ return } + // we must clear tracked uploads before recovery as they no longer have valid refs + inputs.forEach(input => input.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input)) let input = inputs.find(el => el.type !== "hidden") || inputs[0] + let phxEvent = form.getAttribute(this.binding(PHX_AUTO_RECOVER)) || form.getAttribute(this.binding("change")) JS.exec("change", phxEvent, view, input, ["push", {_target: input.name, newCid: newCid, callback: callback}]) }) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 2c5e9d83bc..4c620d1233 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2797,7 +2797,7 @@ defmodule Phoenix.Component do data-phx-active-refs={join_refs(for(entry <- @upload.entries, do: entry.ref))} data-phx-done-refs={join_refs(for(entry <- @upload.entries, entry.done?, do: entry.ref))} data-phx-preflighted-refs={join_refs(for(entry <- @upload.entries, entry.preflighted?, do: entry.ref))} - data-phx-auto-upload={valid_upload?(@upload) and @upload.auto_upload?} + data-phx-auto-upload={@upload.auto_upload?} {if @upload.max_entries > 1, do: Map.put(@rest, :multiple, true), else: @rest} /> """ @@ -2805,9 +2805,6 @@ defmodule Phoenix.Component do defp join_refs(entries), do: Enum.join(entries, ",") - defp valid_upload?(%{entries: [_ | _], errors: []}), do: true - defp valid_upload?(%{}), do: false - @doc """ Generates an image preview on the client for a selected file. diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index 2656bebc37..e3fd3f3f88 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -1863,19 +1863,39 @@ defmodule Phoenix.LiveViewTest do you will need to call `render_submit/1`. """ def render_upload(%Upload{} = upload, entry_name, percent \\ 100) do - if UploadClient.allow_acknowledged?(upload) do - render_chunk(upload, entry_name, percent) - else - case preflight_upload(upload) do - {:ok, %{ref: ref, config: config, entries: entries_resp}} -> - case UploadClient.allowed_ack(upload, ref, config, entries_resp) do - :ok -> render_chunk(upload, entry_name, percent) - {:error, reason} -> {:error, reason} - end + entry_ref = + Enum.find_value(upload.entries, fn + %{"name" => ^entry_name, "ref" => ref} -> ref + %{} -> nil + end) + + unless entry_name do + raise ArgumentError, "no such entry with name #{inspect(entry_name)}" + end - {:error, reason} -> - {:error, reason} - end + case UploadClient.fetch_allow_acknowledged(upload, entry_name) do + {:ok, _token} -> + render_chunk(upload, entry_name, percent) + + {:error, :nopreflight} -> + case preflight_upload(upload) do + {:ok, %{ref: ref, config: config, entries: entries_resp, errors: errors}} -> + if entry_errors = errors[entry_ref] do + UploadClient.allowed_ack(upload, ref, config, entry_name, entries_resp, errors) + {:error, for(reason <- entry_errors , do: [entry_ref, reason])} + else + case UploadClient.allowed_ack(upload, ref, config, entry_name, entries_resp, errors) do + :ok -> render_chunk(upload, entry_name, percent) + {:error, reason} -> {:error, reason} + end + end + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, reason} end end diff --git a/lib/phoenix_live_view/test/upload_client.ex b/lib/phoenix_live_view/test/upload_client.ex index 5930ec02ab..28e92ae0ea 100644 --- a/lib/phoenix_live_view/test/upload_client.ex +++ b/lib/phoenix_live_view/test/upload_client.ex @@ -17,8 +17,8 @@ defmodule Phoenix.LiveViewTest.UploadClient do GenServer.call(pid, :channel_pids) end - def allow_acknowledged?(%Upload{pid: pid}) do - GenServer.call(pid, :allow_acknowledged) + def fetch_allow_acknowledged(%Upload{pid: pid}, entry_name) do + GenServer.call(pid, {:fetch_allow_acknowledged, entry_name}) end def chunk(%Upload{pid: pid, element: element}, name, percent, proxy_pid) do @@ -31,8 +31,8 @@ defmodule Phoenix.LiveViewTest.UploadClient do GenServer.call(pid, {:simulate_attacker_chunk, name, chunk}) end - def allowed_ack(%Upload{pid: pid, entries: entries}, ref, config, entries_resp) do - GenServer.call(pid, {:allowed_ack, ref, config, entries, entries_resp}) + def allowed_ack(%Upload{pid: pid, entries: entries}, ref, config, name, entries_resp, errors) do + GenServer.call(pid, {:allowed_ack, ref, config, name, entries, entries_resp, errors}) end def start_link(opts) do @@ -46,19 +46,38 @@ defmodule Phoenix.LiveViewTest.UploadClient do {:ok, %{socket: socket, cid: cid, upload_ref: nil, config: %{}, entries: %{}}} end - def handle_call(:allow_acknowledged, _from, state) do - {:reply, state.upload_ref != nil, state} + def handle_call({:fetch_allow_acknowledged, entry_name}, _from, state) do + case Map.fetch(state.entries, entry_name) do + {:ok, {:error, reason}} -> {:reply, {:error, reason}, state} + {:ok, token} -> {:reply, {:ok, token}, state} + :error -> {:reply, {:error, :nopreflight}, state} + end end - def handle_call({:allowed_ack, ref, config, entries, entries_resp}, _from, state) do - entries = - for client_entry <- entries, into: %{} do - %{"ref" => ref, "name" => name} = client_entry - token = Map.fetch!(entries_resp, ref) - {name, build_and_join_entry(state, client_entry, token)} - end - - {:reply, :ok, %{state | upload_ref: ref, config: config, entries: entries}} + def handle_call( + {:allowed_ack, upload_ref, config, name, entries, entries_resp, errors}, + _from, + state + ) do + new_entries = + Enum.reduce(entries, state.entries, fn %{"ref" => ref, "name" => name} = client_entry, + acc -> + case Map.fetch(entries_resp, ref) do + {:ok, token} -> + Map.put(acc, name, build_and_join_entry(state, client_entry, token)) + + :error -> + Map.put(acc, name, {:error, Map.get(errors, ref, :not_allowed)}) + end + end) + + new_state = %{state | upload_ref: upload_ref, config: config, entries: new_entries} + + case new_entries do + %{^name => {:error, reason}} -> {:reply, {:error, reason}, new_state} + %{^name => _} -> {:reply, :ok, new_state} + %{} -> raise_unknown_entry!(state, name) + end end def handle_call(:channel_pids, _from, state) do @@ -148,12 +167,23 @@ defmodule Phoenix.LiveViewTest.UploadClient do defp do_chunk(%{socket: nil, cid: cid} = state, from, entry, proxy_pid, element, percent) do stats = progress_stats(entry, percent) - :ok = ClientProxy.report_upload_progress(proxy_pid, from, element, entry.ref, stats.new_percent, cid) + + :ok = + ClientProxy.report_upload_progress( + proxy_pid, + from, + element, + entry.ref, + stats.new_percent, + cid + ) + update_entry_start(state, entry, stats.new_start) end defp do_chunk(state, from, entry, proxy_pid, element, percent) do stats = progress_stats(entry, percent) + chunk = if stats.start + stats.chunk_size > entry.size do :binary.part(entry.content, stats.start, entry.size - stats.start) @@ -165,10 +195,29 @@ defmodule Phoenix.LiveViewTest.UploadClient do receive do %Phoenix.Socket.Reply{ref: ^ref, status: :ok} -> - :ok = ClientProxy.report_upload_progress(proxy_pid, from, element, entry.ref, stats.new_percent, state.cid) + :ok = + ClientProxy.report_upload_progress( + proxy_pid, + from, + element, + entry.ref, + stats.new_percent, + state.cid + ) + update_entry_start(state, entry, stats.new_start) + %Phoenix.Socket.Reply{ref: ^ref, status: :error} -> - :ok = ClientProxy.report_upload_progress(proxy_pid, from, element, entry.ref, %{"error" => "failure"}, state.cid) + :ok = + ClientProxy.report_upload_progress( + proxy_pid, + from, + element, + entry.ref, + %{"error" => "failure"}, + state.cid + ) + update_entry_start(state, entry, stats.new_start) after get_chunk_timeout(state) -> exit(:timeout) @@ -176,17 +225,23 @@ defmodule Phoenix.LiveViewTest.UploadClient do end defp update_entry_start(state, entry, new_start) do - new_entries = Map.update!(state.entries, entry.name, fn entry -> %{entry | chunk_start: new_start} end) + new_entries = + Map.update!(state.entries, entry.name, fn entry -> %{entry | chunk_start: new_start} end) + %{state | entries: new_entries} end defp get_entry!(state, name) do case Map.fetch(state.entries, name) do {:ok, entry} -> entry - :error -> raise "no file input with name \"#{name}\" found in #{inspect(state.entries)}" + :error -> raise_unknown_entry!(state, name) end end + defp raise_unknown_entry!(state, name) do + raise "no file input with name \"#{name}\" found in #{inspect(state.entries)}" + end + defp get_chunk_timeout(state) do state.socket.assigns[:chunk_timeout] || 10_000 end diff --git a/lib/phoenix_live_view/upload.ex b/lib/phoenix_live_view/upload.ex index b2e5ee7a84..e245c748fc 100644 --- a/lib/phoenix_live_view/upload.ex +++ b/lib/phoenix_live_view/upload.ex @@ -371,7 +371,7 @@ defmodule Phoenix.LiveView.Upload do %{} = client_config_meta ) do reply_entries = - for entry <- entries, into: %{} do + for entry <- entries, entry.valid?, into: %{} do token = Phoenix.LiveView.Static.sign_token(socket.endpoint, %{ pid: self(), @@ -382,25 +382,44 @@ defmodule Phoenix.LiveView.Upload do {entry.ref, token} end - {:ok, %{ref: conf.ref, config: client_config_meta, entries: reply_entries}, socket} + errors = + for entry <- entries, not entry.valid?, into: %{}, do: {entry.ref, entry_errors(conf, entry)} + + reply = %{ref: conf.ref, config: client_config_meta, entries: reply_entries, errors: errors} + {:ok, reply, socket} + end + + defp entry_errors(%UploadConfig{} = conf, %UploadEntry{} = entry) do + for {ref, err} <- conf.errors, ref == entry.ref, do: err end defp external_preflight(%Socket{} = socket, %UploadConfig{} = conf, entries, client_config_meta) do reply_entries = - Enum.reduce_while(entries, {:ok, %{}, socket}, fn entry, {:ok, metas, acc} -> - case conf.external.(entry, acc) do - {:ok, %{} = meta, new_socket} -> - new_socket = update_upload_entry_meta(new_socket, conf.name, entry, meta) - {:cont, {:ok, Map.put(metas, entry.ref, meta), new_socket}} - - {:error, %{} = meta, new_socket} -> - {:halt, {:error, {entry.ref, meta}, new_socket}} + Enum.reduce_while(entries, {:ok, %{}, %{}, socket}, fn entry, {:ok, metas, errors, acc} -> + if conf.auto_upload? and not entry.valid? do + new_errors = Map.put(errors, entry.ref, entry.errors) + {:cont, {:ok, metas, new_errors, acc}} + else + case conf.external.(entry, acc) do + {:ok, %{} = meta, new_socket} -> + new_socket = update_upload_entry_meta(new_socket, conf.name, entry, meta) + {:cont, {:ok, Map.put(metas, entry.ref, meta), errors, new_socket}} + + {:error, %{} = meta, new_socket} -> + if conf.auto_upload? do + new_errors = Map.put(errors, entry.ref, [meta]) + {:cont, {:ok, metas, new_errors, new_socket}} + else + {:halt, {:error, {entry.ref, meta}, new_socket}} + end + end end end) case reply_entries do - {:ok, entry_metas, new_socket} -> - {:ok, %{ref: conf.ref, config: client_config_meta, entries: entry_metas}, new_socket} + {:ok, entry_metas, errors, new_socket} -> + reply = %{ref: conf.ref, config: client_config_meta, entries: entry_metas, errors: errors} + {:ok, reply, new_socket} {:error, {entry_ref, meta_reason}, new_socket} -> new_socket = put_upload_error(new_socket, conf.name, entry_ref, meta_reason) diff --git a/lib/phoenix_live_view/upload_config.ex b/lib/phoenix_live_view/upload_config.ex index 7767e1fc42..8ae95f48b2 100644 --- a/lib/phoenix_live_view/upload_config.ex +++ b/lib/phoenix_live_view/upload_config.ex @@ -357,16 +357,20 @@ defmodule Phoenix.LiveView.UploadConfig do def mark_preflighted(%UploadConfig{} = conf) do refs_awaiting = refs_awaiting_preflight(conf) - new_conf = %UploadConfig{ - conf - | entries: for(entry <- conf.entries, do: %UploadEntry{entry | preflighted?: true}) - } + new_entries = + for entry <- conf.entries do + %UploadEntry{entry | preflighted?: entry.preflighted? || entry.ref in refs_awaiting} + end + + new_conf = %UploadConfig{conf | entries: new_entries} {new_conf, for(ref <- refs_awaiting, do: get_entry_by_ref(new_conf, ref))} end defp refs_awaiting_preflight(%UploadConfig{} = conf) do - for entry <- conf.entries, not entry.preflighted?, do: entry.ref + for {entry, i} <- Enum.with_index(conf.entries), + i < conf.max_entries && not entry.preflighted?, + do: entry.ref end @doc false @@ -491,35 +495,44 @@ defmodule Phoenix.LiveView.UploadConfig do @doc false def put_entries(%UploadConfig{} = conf, entries) do - new_entries = - for entry <- entries, !get_entry_by_ref(conf, Map.fetch!(entry, "ref")), do: entry - - pruned_conf = maybe_replace_sole_entry(conf, new_entries) + pruned_conf = maybe_replace_sole_entry(conf, entries) new_conf = - Enum.reduce(new_entries, pruned_conf, fn client_entry, acc -> - case cast_and_validate_entry(acc, client_entry) do - {:ok, new_conf} -> new_conf - {:error, new_conf} -> new_conf + Enum.reduce(entries, pruned_conf, fn client_entry, acc -> + if get_entry_by_ref(acc, Map.fetch!(client_entry, "ref")) do + acc + else + case cast_and_validate_entry(acc, client_entry) do + {:ok, new_conf} -> new_conf + {:error, new_conf} -> new_conf + end end end) - if too_many_files?(new_conf) do - {:error, put_error(new_conf, new_conf.ref, @too_many_files)} - else - case new_conf do - %UploadConfig{errors: []} = new_conf -> - {:ok, new_conf} + too_many? = too_many_files?(new_conf) - %UploadConfig{errors: [_ | _]} = new_conf -> - {:error, new_conf} - end + cond do + too_many? && new_conf.auto_upload? -> + {:ok, put_error(new_conf, new_conf.ref, @too_many_files)} + + too_many? -> + {:error, put_error(new_conf, new_conf.ref, @too_many_files)} + + new_conf.auto_upload? -> + {:ok, new_conf} + + new_conf.errors != [] -> + {:error, new_conf} + + true -> + {:ok, new_conf} end end defp maybe_replace_sole_entry(%UploadConfig{max_entries: 1} = conf, new_entries) do with [entry] <- conf.entries, - [_new_entry] <- new_entries do + [new_entry] <- new_entries, + true <- entry.ref != Map.fetch!(new_entry, "ref") do cancel_entry(conf, entry) else _ -> conf diff --git a/priv/static/phoenix_live_view.cjs.js b/priv/static/phoenix_live_view.cjs.js index 1facd9f81f..22fda48709 100644 --- a/priv/static/phoenix_live_view.cjs.js +++ b/priv/static/phoenix_live_view.cjs.js @@ -304,6 +304,9 @@ var DOM = { isUploadInput(el) { return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; }, + isAutoUpload(inputEl) { + return inputEl.hasAttribute("data-phx-auto-upload"); + }, findUploadInputs(node) { return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`); }, @@ -793,7 +796,9 @@ var UploadEntry = class { error(reason = "failed") { this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); this.view.pushFileProgress(this.fileEl, this.ref, { error: reason }); - LiveUploader.clearFiles(this.fileEl); + if (!dom_default.isAutoUpload(this.fileEl)) { + LiveUploader.clearFiles(this.fileEl); + } } onDone(callback) { this._onDone = () => { @@ -3405,7 +3410,7 @@ var View = class { }; this.pushWithReply(refGenerator, "event", event, (resp) => { dom_default.showError(inputEl, this.liveSocket.binding(PHX_FEEDBACK_FOR)); - if (dom_default.isUploadInput(inputEl) && inputEl.getAttribute("data-phx-auto-upload") !== null) { + if (dom_default.isUploadInput(inputEl) && dom_default.isAutoUpload(inputEl)) { if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) { let [ref, _els] = refGenerator(); this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => { diff --git a/priv/static/phoenix_live_view.cjs.js.map b/priv/static/phoenix_live_view.cjs.js.map index 6036481fa9..0f414a2595 100644 --- a/priv/static/phoenix_live_view.cjs.js.map +++ b/priv/static/phoenix_live_view.cjs.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../assets/js/phoenix_live_view/index.js", "../../assets/js/phoenix_live_view/constants.js", "../../assets/js/phoenix_live_view/entry_uploader.js", "../../assets/js/phoenix_live_view/utils.js", "../../assets/js/phoenix_live_view/browser.js", "../../assets/js/phoenix_live_view/dom.js", "../../assets/js/phoenix_live_view/upload_entry.js", "../../assets/js/phoenix_live_view/live_uploader.js", "../../assets/js/phoenix_live_view/aria.js", "../../assets/js/phoenix_live_view/hooks.js", "../../assets/js/phoenix_live_view/dom_post_morph_restorer.js", "../../assets/node_modules/morphdom/dist/morphdom-esm.js", "../../assets/js/phoenix_live_view/dom_patch.js", "../../assets/js/phoenix_live_view/rendered.js", "../../assets/js/phoenix_live_view/view_hook.js", "../../assets/js/phoenix_live_view/js.js", "../../assets/js/phoenix_live_view/view.js", "../../assets/js/phoenix_live_view/live_socket.js"], - "sourcesContent": ["/*\n================================================================================\nPhoenix LiveView JavaScript Client\n================================================================================\n\nSee the hexdocs at `https://hexdocs.pm/phoenix_live_view` for documentation.\n\n*/\n\nimport LiveSocket from \"./live_socket\"\nexport {\n LiveSocket\n}\n", "export const CONSECUTIVE_RELOADS = \"consecutive-reloads\"\nexport const MAX_RELOADS = 10\nexport const RELOAD_JITTER_MIN = 5000\nexport const RELOAD_JITTER_MAX = 10000\nexport const FAILSAFE_JITTER = 30000\nexport const PHX_EVENT_CLASSES = [\n \"phx-click-loading\", \"phx-change-loading\", \"phx-submit-loading\",\n \"phx-keydown-loading\", \"phx-keyup-loading\", \"phx-blur-loading\", \"phx-focus-loading\"\n]\nexport const PHX_COMPONENT = \"data-phx-component\"\nexport const PHX_LIVE_LINK = \"data-phx-link\"\nexport const PHX_TRACK_STATIC = \"track-static\"\nexport const PHX_LINK_STATE = \"data-phx-link-state\"\nexport const PHX_REF = \"data-phx-ref\"\nexport const PHX_REF_SRC = \"data-phx-ref-src\"\nexport const PHX_TRACK_UPLOADS = \"track-uploads\"\nexport const PHX_UPLOAD_REF = \"data-phx-upload-ref\"\nexport const PHX_PREFLIGHTED_REFS = \"data-phx-preflighted-refs\"\nexport const PHX_DONE_REFS = \"data-phx-done-refs\"\nexport const PHX_DROP_TARGET = \"drop-target\"\nexport const PHX_ACTIVE_ENTRY_REFS = \"data-phx-active-refs\"\nexport const PHX_LIVE_FILE_UPDATED = \"phx:live-file:updated\"\nexport const PHX_SKIP = \"data-phx-skip\"\nexport const PHX_PRUNE = \"data-phx-prune\"\nexport const PHX_PAGE_LOADING = \"page-loading\"\nexport const PHX_CONNECTED_CLASS = \"phx-connected\"\nexport const PHX_LOADING_CLASS = \"phx-loading\"\nexport const PHX_NO_FEEDBACK_CLASS = \"phx-no-feedback\"\nexport const PHX_ERROR_CLASS = \"phx-error\"\nexport const PHX_CLIENT_ERROR_CLASS = \"phx-client-error\"\nexport const PHX_SERVER_ERROR_CLASS = \"phx-server-error\"\nexport const PHX_PARENT_ID = \"data-phx-parent-id\"\nexport const PHX_MAIN = \"data-phx-main\"\nexport const PHX_ROOT_ID = \"data-phx-root-id\"\nexport const PHX_VIEWPORT_TOP = \"viewport-top\"\nexport const PHX_VIEWPORT_BOTTOM = \"viewport-bottom\"\nexport const PHX_TRIGGER_ACTION = \"trigger-action\"\nexport const PHX_FEEDBACK_FOR = \"feedback-for\"\nexport const PHX_HAS_FOCUSED = \"phx-has-focused\"\nexport const FOCUSABLE_INPUTS = [\"text\", \"textarea\", \"number\", \"email\", \"password\", \"search\", \"tel\", \"url\", \"date\", \"time\", \"datetime-local\", \"color\", \"range\"]\nexport const CHECKABLE_INPUTS = [\"checkbox\", \"radio\"]\nexport const PHX_HAS_SUBMITTED = \"phx-has-submitted\"\nexport const PHX_SESSION = \"data-phx-session\"\nexport const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`\nexport const PHX_STICKY = \"data-phx-sticky\"\nexport const PHX_STATIC = \"data-phx-static\"\nexport const PHX_READONLY = \"data-phx-readonly\"\nexport const PHX_DISABLED = \"data-phx-disabled\"\nexport const PHX_DISABLE_WITH = \"disable-with\"\nexport const PHX_DISABLE_WITH_RESTORE = \"data-phx-disable-with-restore\"\nexport const PHX_HOOK = \"hook\"\nexport const PHX_DEBOUNCE = \"debounce\"\nexport const PHX_THROTTLE = \"throttle\"\nexport const PHX_UPDATE = \"update\"\nexport const PHX_STREAM = \"stream\"\nexport const PHX_STREAM_REF = \"data-phx-stream\"\nexport const PHX_KEY = \"key\"\nexport const PHX_PRIVATE = \"phxPrivate\"\nexport const PHX_AUTO_RECOVER = \"auto-recover\"\nexport const PHX_LV_DEBUG = \"phx:live-socket:debug\"\nexport const PHX_LV_PROFILE = \"phx:live-socket:profiling\"\nexport const PHX_LV_LATENCY_SIM = \"phx:live-socket:latency-sim\"\nexport const PHX_PROGRESS = \"progress\"\nexport const PHX_MOUNTED = \"mounted\"\nexport const LOADER_TIMEOUT = 1\nexport const BEFORE_UNLOAD_LOADER_TIMEOUT = 200\nexport const BINDING_PREFIX = \"phx-\"\nexport const PUSH_TIMEOUT = 30000\nexport const LINK_HEADER = \"x-requested-with\"\nexport const RESPONSE_URL_HEADER = \"x-response-url\"\nexport const DEBOUNCE_TRIGGER = \"debounce-trigger\"\nexport const THROTTLED = \"throttled\"\nexport const DEBOUNCE_PREV_KEY = \"debounce-prev-key\"\nexport const DEFAULTS = {\n debounce: 300,\n throttle: 300\n}\n\n// Rendered\nexport const DYNAMICS = \"d\"\nexport const STATIC = \"s\"\nexport const COMPONENTS = \"c\"\nexport const EVENTS = \"e\"\nexport const REPLY = \"r\"\nexport const TITLE = \"t\"\nexport const TEMPLATES = \"p\"\nexport const STREAM = \"stream\"\n", "import {\n logError\n} from \"./utils\"\n\nexport default class EntryUploader {\n constructor(entry, chunkSize, liveSocket){\n this.liveSocket = liveSocket\n this.entry = entry\n this.offset = 0\n this.chunkSize = chunkSize\n this.chunkTimer = null\n this.errored = false\n this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, {token: entry.metadata()})\n }\n\n error(reason){\n if(this.errored){ return }\n this.errored = true\n clearTimeout(this.chunkTimer)\n this.entry.error(reason)\n }\n\n upload(){\n this.uploadChannel.onError(reason => this.error(reason))\n this.uploadChannel.join()\n .receive(\"ok\", _data => this.readNextChunk())\n .receive(\"error\", reason => this.error(reason))\n }\n\n isDone(){ return this.offset >= this.entry.file.size }\n\n readNextChunk(){\n let reader = new window.FileReader()\n let blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset)\n reader.onload = (e) => {\n if(e.target.error === null){\n this.offset += e.target.result.byteLength\n this.pushChunk(e.target.result)\n } else {\n return logError(\"Read error: \" + e.target.error)\n }\n }\n reader.readAsArrayBuffer(blob)\n }\n\n pushChunk(chunk){\n if(!this.uploadChannel.isJoined()){ return }\n this.uploadChannel.push(\"chunk\", chunk)\n .receive(\"ok\", () => {\n this.entry.progress((this.offset / this.entry.file.size) * 100)\n if(!this.isDone()){\n this.chunkTimer = setTimeout(() => this.readNextChunk(), this.liveSocket.getLatencySim() || 0)\n }\n })\n .receive(\"error\", ({reason}) => this.error(reason))\n }\n}\n", "import {\n PHX_VIEW_SELECTOR\n} from \"./constants\"\n\nimport EntryUploader from \"./entry_uploader\"\n\nexport let logError = (msg, obj) => console.error && console.error(msg, obj)\n\nexport let isCid = (cid) => {\n let type = typeof(cid)\n return type === \"number\" || (type === \"string\" && /^(0|[1-9]\\d*)$/.test(cid))\n}\n\nexport function detectDuplicateIds(){\n let ids = new Set()\n let elems = document.querySelectorAll(\"*[id]\")\n for(let i = 0, len = elems.length; i < len; i++){\n if(ids.has(elems[i].id)){\n console.error(`Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`)\n } else {\n ids.add(elems[i].id)\n }\n }\n}\n\nexport let debug = (view, kind, msg, obj) => {\n if(view.liveSocket.isDebugEnabled()){\n console.log(`${view.id} ${kind}: ${msg} - `, obj)\n }\n}\n\n// wraps value in closure or returns closure\nexport let closure = (val) => typeof val === \"function\" ? val : function (){ return val }\n\nexport let clone = (obj) => { return JSON.parse(JSON.stringify(obj)) }\n\nexport let closestPhxBinding = (el, binding, borderEl) => {\n do {\n if(el.matches(`[${binding}]`) && !el.disabled){ return el }\n el = el.parentElement || el.parentNode\n } while(el !== null && el.nodeType === 1 && !((borderEl && borderEl.isSameNode(el)) || el.matches(PHX_VIEW_SELECTOR)))\n return null\n}\n\nexport let isObject = (obj) => {\n return obj !== null && typeof obj === \"object\" && !(obj instanceof Array)\n}\n\nexport let isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2)\n\nexport let isEmpty = (obj) => {\n for(let x in obj){ return false }\n return true\n}\n\nexport let maybe = (el, callback) => el && callback(el)\n\nexport let channelUploader = function (entries, onError, resp, liveSocket){\n entries.forEach(entry => {\n let entryUploader = new EntryUploader(entry, resp.config.chunk_size, liveSocket)\n entryUploader.upload()\n })\n}\n", "let Browser = {\n canPushState(){ return (typeof (history.pushState) !== \"undefined\") },\n\n dropLocal(localStorage, namespace, subkey){\n return localStorage.removeItem(this.localKey(namespace, subkey))\n },\n\n updateLocal(localStorage, namespace, subkey, initial, func){\n let current = this.getLocal(localStorage, namespace, subkey)\n let key = this.localKey(namespace, subkey)\n let newVal = current === null ? initial : func(current)\n localStorage.setItem(key, JSON.stringify(newVal))\n return newVal\n },\n\n getLocal(localStorage, namespace, subkey){\n return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey)))\n },\n\n updateCurrentState(callback){\n if(!this.canPushState()){ return }\n history.replaceState(callback(history.state || {}), \"\", window.location.href)\n },\n\n pushState(kind, meta, to){\n if(this.canPushState()){\n if(to !== window.location.href){\n if(meta.type == \"redirect\" && meta.scroll){\n // If we're redirecting store the current scrollY for the current history state.\n let currentState = history.state || {}\n currentState.scroll = meta.scroll\n history.replaceState(currentState, \"\", window.location.href)\n }\n\n delete meta.scroll // Only store the scroll in the redirect case.\n history[kind + \"State\"](meta, \"\", to || null) // IE will coerce undefined to string\n let hashEl = this.getHashTargetEl(window.location.hash)\n\n if(hashEl){\n hashEl.scrollIntoView()\n } else if(meta.type === \"redirect\"){\n window.scroll(0, 0)\n }\n }\n } else {\n this.redirect(to)\n }\n },\n\n setCookie(name, value){\n document.cookie = `${name}=${value}`\n },\n\n getCookie(name){\n return document.cookie.replace(new RegExp(`(?:(?:^|.*;\\s*)${name}\\s*\\=\\s*([^;]*).*$)|^.*$`), \"$1\")\n },\n\n redirect(toURL, flash){\n if(flash){ Browser.setCookie(\"__phoenix_flash__\", flash + \"; max-age=60000; path=/\") }\n window.location = toURL\n },\n\n localKey(namespace, subkey){ return `${namespace}-${subkey}` },\n\n getHashTargetEl(maybeHash){\n let hash = maybeHash.toString().substring(1)\n if(hash === \"\"){ return }\n return document.getElementById(hash) || document.querySelector(`a[name=\"${hash}\"]`)\n }\n}\n\nexport default Browser\n", "import {\n CHECKABLE_INPUTS,\n DEBOUNCE_PREV_KEY,\n DEBOUNCE_TRIGGER,\n FOCUSABLE_INPUTS,\n PHX_COMPONENT,\n PHX_EVENT_CLASSES,\n PHX_HAS_FOCUSED,\n PHX_HAS_SUBMITTED,\n PHX_MAIN,\n PHX_NO_FEEDBACK_CLASS,\n PHX_PARENT_ID,\n PHX_PRIVATE,\n PHX_REF,\n PHX_REF_SRC,\n PHX_ROOT_ID,\n PHX_SESSION,\n PHX_STATIC,\n PHX_UPLOAD_REF,\n PHX_VIEW_SELECTOR,\n PHX_STICKY,\n THROTTLED\n} from \"./constants\"\n\nimport {\n logError\n} from \"./utils\"\n\nlet DOM = {\n byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) },\n\n removeClass(el, className){\n el.classList.remove(className)\n if(el.classList.length === 0){ el.removeAttribute(\"class\") }\n },\n\n all(node, query, callback){\n if(!node){ return [] }\n let array = Array.from(node.querySelectorAll(query))\n return callback ? array.forEach(callback) : array\n },\n\n childNodeLength(html){\n let template = document.createElement(\"template\")\n template.innerHTML = html\n return template.content.childElementCount\n },\n\n isUploadInput(el){ return el.type === \"file\" && el.getAttribute(PHX_UPLOAD_REF) !== null },\n\n findUploadInputs(node){ return this.all(node, `input[type=\"file\"][${PHX_UPLOAD_REF}]`) },\n\n findComponentNodeList(node, cid){\n return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}=\"${cid}\"]`), node)\n },\n\n isPhxDestroyed(node){\n return node.id && DOM.private(node, \"destroyed\") ? true : false\n },\n\n wantsNewTab(e){\n let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)\n let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute(\"download\"))\n let isTargetBlank = e.target.hasAttribute(\"target\") && e.target.getAttribute(\"target\").toLowerCase() === \"_blank\"\n return wantsNewTab || isTargetBlank || isDownload\n },\n\n isUnloadableFormSubmit(e){\n return !e.defaultPrevented && !this.wantsNewTab(e)\n },\n\n isNewPageClick(e, currentLocation){\n let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute(\"href\") : null\n let url\n\n if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false }\n if(href.startsWith(\"mailto:\") || href.startsWith(\"tel:\")){ return false }\n\n try {\n url = new URL(href)\n } catch(e) {\n try {\n url = new URL(href, currentLocation)\n } catch(e) {\n // bad URL, fallback to let browser try it as external\n return true\n }\n }\n\n if(url.host === currentLocation.host && url.protocol === currentLocation.protocol){\n if(url.pathname === currentLocation.pathname && url.search === currentLocation.search){\n return url.hash === \"\" && !url.href.endsWith(\"#\")\n }\n }\n return url.protocol.startsWith(\"http\")\n },\n\n markPhxChildDestroyed(el){\n if(this.isPhxChild(el)){ el.setAttribute(PHX_SESSION, \"\") }\n this.putPrivate(el, \"destroyed\", true)\n },\n\n findPhxChildrenInFragment(html, parentId){\n let template = document.createElement(\"template\")\n template.innerHTML = html\n return this.findPhxChildren(template.content, parentId)\n },\n\n isIgnored(el, phxUpdate){\n return (el.getAttribute(phxUpdate) || el.getAttribute(\"data-phx-update\")) === \"ignore\"\n },\n\n isPhxUpdate(el, phxUpdate, updateTypes){\n return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0\n },\n\n findPhxSticky(el){ return this.all(el, `[${PHX_STICKY}]`) },\n\n findPhxChildren(el, parentId){\n return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}=\"${parentId}\"]`)\n },\n\n findParentCIDs(node, cids){\n let initial = new Set(cids)\n let parentCids =\n cids.reduce((acc, cid) => {\n let selector = `[${PHX_COMPONENT}=\"${cid}\"] [${PHX_COMPONENT}]`\n\n this.filterWithinSameLiveView(this.all(node, selector), node)\n .map(el => parseInt(el.getAttribute(PHX_COMPONENT)))\n .forEach(childCID => acc.delete(childCID))\n\n return acc\n }, initial)\n\n return parentCids.size === 0 ? new Set(cids) : parentCids\n },\n\n filterWithinSameLiveView(nodes, parent){\n if(parent.querySelector(PHX_VIEW_SELECTOR)){\n return nodes.filter(el => this.withinSameLiveView(el, parent))\n } else {\n return nodes\n }\n },\n\n withinSameLiveView(node, parent){\n while(node = node.parentNode){\n if(node.isSameNode(parent)){ return true }\n if(node.getAttribute(PHX_SESSION) !== null){ return false }\n }\n },\n\n private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] },\n\n deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) },\n\n putPrivate(el, key, value){\n if(!el[PHX_PRIVATE]){ el[PHX_PRIVATE] = {} }\n el[PHX_PRIVATE][key] = value\n },\n\n updatePrivate(el, key, defaultVal, updateFunc){\n let existing = this.private(el, key)\n if(existing === undefined){\n this.putPrivate(el, key, updateFunc(defaultVal))\n } else {\n this.putPrivate(el, key, updateFunc(existing))\n }\n },\n\n copyPrivates(target, source){\n if(source[PHX_PRIVATE]){\n target[PHX_PRIVATE] = source[PHX_PRIVATE]\n }\n },\n\n putTitle(str){\n let titleEl = document.querySelector(\"title\")\n if(titleEl){\n let {prefix, suffix} = titleEl.dataset\n document.title = `${prefix || \"\"}${str}${suffix || \"\"}`\n } else {\n document.title = str\n }\n },\n\n debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){\n let debounce = el.getAttribute(phxDebounce)\n let throttle = el.getAttribute(phxThrottle)\n\n if(debounce === \"\"){ debounce = defaultDebounce }\n if(throttle === \"\"){ throttle = defaultThrottle }\n let value = debounce || throttle\n switch(value){\n case null: return callback()\n\n case \"blur\":\n if(this.once(el, \"debounce-blur\")){\n el.addEventListener(\"blur\", () => callback())\n }\n return\n\n default:\n let timeout = parseInt(value)\n let trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback()\n let currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger)\n if(isNaN(timeout)){ return logError(`invalid throttle/debounce value: ${value}`) }\n if(throttle){\n let newKeyDown = false\n if(event.type === \"keydown\"){\n let prevKey = this.private(el, DEBOUNCE_PREV_KEY)\n this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key)\n newKeyDown = prevKey !== event.key\n }\n\n if(!newKeyDown && this.private(el, THROTTLED)){\n return false\n } else {\n callback()\n this.putPrivate(el, THROTTLED, true)\n setTimeout(() => {\n if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER) }\n }, timeout)\n }\n } else {\n setTimeout(() => {\n if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle) }\n }, timeout)\n }\n\n let form = el.form\n if(form && this.once(form, \"bind-debounce\")){\n form.addEventListener(\"submit\", () => {\n Array.from((new FormData(form)).entries(), ([name]) => {\n let input = form.querySelector(`[name=\"${name}\"]`)\n this.incCycle(input, DEBOUNCE_TRIGGER)\n this.deletePrivate(input, THROTTLED)\n })\n })\n }\n if(this.once(el, \"bind-debounce\")){\n el.addEventListener(\"blur\", () => this.triggerCycle(el, DEBOUNCE_TRIGGER))\n }\n }\n },\n\n triggerCycle(el, key, currentCycle){\n let [cycle, trigger] = this.private(el, key)\n if(!currentCycle){ currentCycle = cycle }\n if(currentCycle === cycle){\n this.incCycle(el, key)\n trigger()\n }\n },\n\n once(el, key){\n if(this.private(el, key) === true){ return false }\n this.putPrivate(el, key, true)\n return true\n },\n\n incCycle(el, key, trigger = function (){ }){\n let [currentCycle] = this.private(el, key) || [0, trigger]\n currentCycle++\n this.putPrivate(el, key, [currentCycle, trigger])\n return currentCycle\n },\n\n maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){\n if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){\n el.setAttribute(\"data-phx-hook\", \"Phoenix.InfiniteScroll\")\n }\n },\n\n maybeHideFeedback(container, input, phxFeedbackFor){\n if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){\n let feedbacks = [input.name]\n if(input.name.endsWith(\"[]\")){ feedbacks.push(input.name.slice(0, -2)) }\n let selector = feedbacks.map(f => `[${phxFeedbackFor}=\"${f}\"]`).join(\", \")\n DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))\n }\n },\n\n resetForm(form, phxFeedbackFor){\n Array.from(form.elements).forEach(input => {\n let query = `[${phxFeedbackFor}=\"${input.id}\"],\n [${phxFeedbackFor}=\"${input.name}\"],\n [${phxFeedbackFor}=\"${input.name.replace(/\\[\\]$/, \"\")}\"]`\n\n this.deletePrivate(input, PHX_HAS_FOCUSED)\n this.deletePrivate(input, PHX_HAS_SUBMITTED)\n this.all(document, query, feedbackEl => {\n feedbackEl.classList.add(PHX_NO_FEEDBACK_CLASS)\n })\n })\n },\n\n showError(inputEl, phxFeedbackFor){\n if(inputEl.id || inputEl.name){\n this.all(inputEl.form, `[${phxFeedbackFor}=\"${inputEl.id}\"], [${phxFeedbackFor}=\"${inputEl.name}\"]`, (el) => {\n this.removeClass(el, PHX_NO_FEEDBACK_CLASS)\n })\n }\n },\n\n isPhxChild(node){\n return node.getAttribute && node.getAttribute(PHX_PARENT_ID)\n },\n\n isPhxSticky(node){\n return node.getAttribute && node.getAttribute(PHX_STICKY) !== null\n },\n\n firstPhxChild(el){\n return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]\n },\n\n dispatchEvent(target, name, opts = {}){\n let bubbles = opts.bubbles === undefined ? true : !!opts.bubbles\n let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}}\n let event = name === \"click\" ? new MouseEvent(\"click\", eventOpts) : new CustomEvent(name, eventOpts)\n target.dispatchEvent(event)\n },\n\n cloneNode(node, html){\n if(typeof (html) === \"undefined\"){\n return node.cloneNode(true)\n } else {\n let cloned = node.cloneNode(false)\n cloned.innerHTML = html\n return cloned\n }\n },\n\n mergeAttrs(target, source, opts = {}){\n let exclude = opts.exclude || []\n let isIgnored = opts.isIgnored\n let sourceAttrs = source.attributes\n for(let i = sourceAttrs.length - 1; i >= 0; i--){\n let name = sourceAttrs[i].name\n if(exclude.indexOf(name) < 0){ target.setAttribute(name, source.getAttribute(name)) }\n }\n\n let targetAttrs = target.attributes\n for(let i = targetAttrs.length - 1; i >= 0; i--){\n let name = targetAttrs[i].name\n if(isIgnored){\n if(name.startsWith(\"data-\") && !source.hasAttribute(name)){ target.removeAttribute(name) }\n } else {\n if(!source.hasAttribute(name)){ target.removeAttribute(name) }\n }\n }\n },\n\n mergeFocusedInput(target, source){\n // skip selects because FF will reset highlighted index for any setAttribute\n if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: [\"value\"]}) }\n if(source.readOnly){\n target.setAttribute(\"readonly\", true)\n } else {\n target.removeAttribute(\"readonly\")\n }\n },\n\n hasSelectionRange(el){\n return el.setSelectionRange && (el.type === \"text\" || el.type === \"textarea\")\n },\n\n restoreFocus(focused, selectionStart, selectionEnd){\n if(!DOM.isTextualInput(focused)){ return }\n let wasFocused = focused.matches(\":focus\")\n if(focused.readOnly){ focused.blur() }\n if(!wasFocused){ focused.focus() }\n if(this.hasSelectionRange(focused)){\n focused.setSelectionRange(selectionStart, selectionEnd)\n }\n },\n\n isFormInput(el){ return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== \"button\" },\n\n syncAttrsToProps(el){\n if(el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0){\n el.checked = el.getAttribute(\"checked\") !== null\n }\n },\n\n isTextualInput(el){ return FOCUSABLE_INPUTS.indexOf(el.type) >= 0 },\n\n isNowTriggerFormExternal(el, phxTriggerExternal){\n return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null\n },\n\n syncPendingRef(fromEl, toEl, disableWith){\n let ref = fromEl.getAttribute(PHX_REF)\n if(ref === null){ return true }\n let refSrc = fromEl.getAttribute(PHX_REF_SRC)\n\n if(DOM.isFormInput(fromEl) || fromEl.getAttribute(disableWith) !== null){\n if(DOM.isUploadInput(fromEl)){ DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) }\n DOM.putPrivate(fromEl, PHX_REF, toEl)\n return false\n } else {\n PHX_EVENT_CLASSES.forEach(className => {\n fromEl.classList.contains(className) && toEl.classList.add(className)\n })\n toEl.setAttribute(PHX_REF, ref)\n toEl.setAttribute(PHX_REF_SRC, refSrc)\n return true\n }\n },\n\n cleanChildNodes(container, phxUpdate){\n if(DOM.isPhxUpdate(container, phxUpdate, [\"append\", \"prepend\"])){\n let toRemove = []\n container.childNodes.forEach(childNode => {\n if(!childNode.id){\n // Skip warning if it's an empty text node (e.g. a new-line)\n let isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === \"\"\n if(!isEmptyTextNode){\n logError(\"only HTML element tags with an id are allowed inside containers with phx-update.\\n\\n\" +\n `removing illegal node: \"${(childNode.outerHTML || childNode.nodeValue).trim()}\"\\n\\n`)\n }\n toRemove.push(childNode)\n }\n })\n toRemove.forEach(childNode => childNode.remove())\n }\n },\n\n replaceRootContainer(container, tagName, attrs){\n let retainedAttrs = new Set([\"id\", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID])\n if(container.tagName.toLowerCase() === tagName.toLowerCase()){\n Array.from(container.attributes)\n .filter(attr => !retainedAttrs.has(attr.name.toLowerCase()))\n .forEach(attr => container.removeAttribute(attr.name))\n\n Object.keys(attrs)\n .filter(name => !retainedAttrs.has(name.toLowerCase()))\n .forEach(attr => container.setAttribute(attr, attrs[attr]))\n\n return container\n\n } else {\n let newContainer = document.createElement(tagName)\n Object.keys(attrs).forEach(attr => newContainer.setAttribute(attr, attrs[attr]))\n retainedAttrs.forEach(attr => newContainer.setAttribute(attr, container.getAttribute(attr)))\n newContainer.innerHTML = container.innerHTML\n container.replaceWith(newContainer)\n return newContainer\n }\n },\n\n getSticky(el, name, defaultVal){\n let op = (DOM.private(el, \"sticky\") || []).find(([existingName, ]) => name === existingName)\n if(op){\n let [_name, _op, stashedResult] = op\n return stashedResult\n } else {\n return typeof(defaultVal) === \"function\" ? defaultVal() : defaultVal\n }\n },\n\n deleteSticky(el, name){\n this.updatePrivate(el, \"sticky\", [], ops => {\n return ops.filter(([existingName, _]) => existingName !== name)\n })\n },\n\n putSticky(el, name, op){\n let stashedResult = op(el)\n this.updatePrivate(el, \"sticky\", [], ops => {\n let existingIndex = ops.findIndex(([existingName, ]) => name === existingName)\n if(existingIndex >= 0){\n ops[existingIndex] = [name, op, stashedResult]\n } else {\n ops.push([name, op, stashedResult])\n }\n return ops\n })\n },\n\n applyStickyOperations(el){\n let ops = DOM.private(el, \"sticky\")\n if(!ops){ return }\n\n ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op))\n }\n}\n\nexport default DOM\n", "import {\n PHX_ACTIVE_ENTRY_REFS,\n PHX_LIVE_FILE_UPDATED,\n PHX_PREFLIGHTED_REFS\n} from \"./constants\"\n\nimport {\n channelUploader,\n logError\n} from \"./utils\"\n\nimport LiveUploader from \"./live_uploader\"\n\nexport default class UploadEntry {\n static isActive(fileEl, file){\n let isNew = file._phxRef === undefined\n let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(\",\")\n let isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0\n return file.size > 0 && (isNew || isActive)\n }\n\n static isPreflighted(fileEl, file){\n let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(\",\")\n let isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0\n return isPreflighted && this.isActive(fileEl, file)\n }\n\n constructor(fileEl, file, view){\n this.ref = LiveUploader.genFileRef(file)\n this.fileEl = fileEl\n this.file = file\n this.view = view\n this.meta = null\n this._isCancelled = false\n this._isDone = false\n this._progress = 0\n this._lastProgressSent = -1\n this._onDone = function (){ }\n this._onElUpdated = this.onElUpdated.bind(this)\n this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)\n }\n\n metadata(){ return this.meta }\n\n progress(progress){\n this._progress = Math.floor(progress)\n if(this._progress > this._lastProgressSent){\n if(this._progress >= 100){\n this._progress = 100\n this._lastProgressSent = 100\n this._isDone = true\n this.view.pushFileProgress(this.fileEl, this.ref, 100, () => {\n LiveUploader.untrackFile(this.fileEl, this.file)\n this._onDone()\n })\n } else {\n this._lastProgressSent = this._progress\n this.view.pushFileProgress(this.fileEl, this.ref, this._progress)\n }\n }\n }\n\n cancel(){\n this._isCancelled = true\n this._isDone = true\n this._onDone()\n }\n\n isDone(){ return this._isDone }\n\n error(reason = \"failed\"){\n this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)\n this.view.pushFileProgress(this.fileEl, this.ref, {error: reason})\n LiveUploader.clearFiles(this.fileEl)\n }\n\n //private\n\n onDone(callback){\n this._onDone = () => {\n this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)\n callback()\n }\n }\n\n onElUpdated(){\n let activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(\",\")\n if(activeRefs.indexOf(this.ref) === -1){ this.cancel() }\n }\n\n toPreflightPayload(){\n return {\n last_modified: this.file.lastModified,\n name: this.file.name,\n relative_path: this.file.webkitRelativePath,\n size: this.file.size,\n type: this.file.type,\n ref: this.ref\n }\n }\n\n uploader(uploaders){\n if(this.meta.uploader){\n let callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`)\n return {name: this.meta.uploader, callback: callback}\n } else {\n return {name: \"channel\", callback: channelUploader}\n }\n }\n\n zipPostFlight(resp){\n this.meta = resp.entries[this.ref]\n if(!this.meta){ logError(`no preflight upload response returned with ref ${this.ref}`, {input: this.fileEl, response: resp}) }\n }\n}\n", "import {\n PHX_DONE_REFS,\n PHX_PREFLIGHTED_REFS,\n PHX_UPLOAD_REF\n} from \"./constants\"\n\nimport {\n} from \"./utils\"\n\nimport DOM from \"./dom\"\nimport UploadEntry from \"./upload_entry\"\n\nlet liveUploaderFileRef = 0\n\nexport default class LiveUploader {\n static genFileRef(file){\n let ref = file._phxRef\n if(ref !== undefined){\n return ref\n } else {\n file._phxRef = (liveUploaderFileRef++).toString()\n return file._phxRef\n }\n }\n\n static getEntryDataURL(inputEl, ref, callback){\n let file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref)\n callback(URL.createObjectURL(file))\n }\n\n static hasUploadsInProgress(formEl){\n let active = 0\n DOM.findUploadInputs(formEl).forEach(input => {\n if(input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)){\n active++\n }\n })\n return active > 0\n }\n\n static serializeUploads(inputEl){\n let files = this.activeFiles(inputEl)\n let fileData = {}\n files.forEach(file => {\n let entry = {path: inputEl.name}\n let uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF)\n fileData[uploadRef] = fileData[uploadRef] || []\n entry.ref = this.genFileRef(file)\n entry.last_modified = file.lastModified\n entry.name = file.name || entry.ref\n entry.relative_path = file.webkitRelativePath\n entry.type = file.type\n entry.size = file.size\n fileData[uploadRef].push(entry)\n })\n return fileData\n }\n\n static clearFiles(inputEl){\n inputEl.value = null\n inputEl.removeAttribute(PHX_UPLOAD_REF)\n DOM.putPrivate(inputEl, \"files\", [])\n }\n\n static untrackFile(inputEl, file){\n DOM.putPrivate(inputEl, \"files\", DOM.private(inputEl, \"files\").filter(f => !Object.is(f, file)))\n }\n\n static trackFiles(inputEl, files, dataTransfer){\n if(inputEl.getAttribute(\"multiple\") !== null){\n let newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file)))\n DOM.putPrivate(inputEl, \"files\", this.activeFiles(inputEl).concat(newFiles))\n inputEl.value = null\n } else {\n // Reset inputEl files to align output with programmatic changes (i.e. drag and drop)\n if(dataTransfer && dataTransfer.files.length > 0){ inputEl.files = dataTransfer.files }\n DOM.putPrivate(inputEl, \"files\", files)\n }\n }\n\n static activeFileInputs(formEl){\n let fileInputs = DOM.findUploadInputs(formEl)\n return Array.from(fileInputs).filter(el => el.files && this.activeFiles(el).length > 0)\n }\n\n static activeFiles(input){\n return (DOM.private(input, \"files\") || []).filter(f => UploadEntry.isActive(input, f))\n }\n\n static inputsAwaitingPreflight(formEl){\n let fileInputs = DOM.findUploadInputs(formEl)\n return Array.from(fileInputs).filter(input => this.filesAwaitingPreflight(input).length > 0)\n }\n\n static filesAwaitingPreflight(input){\n return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f))\n }\n\n constructor(inputEl, view, onComplete){\n this.view = view\n this.onComplete = onComplete\n this._entries =\n Array.from(LiveUploader.filesAwaitingPreflight(inputEl) || [])\n .map(file => new UploadEntry(inputEl, file, view))\n\n this.numEntriesInProgress = this._entries.length\n }\n\n entries(){ return this._entries }\n\n initAdapterUpload(resp, onError, liveSocket){\n this._entries =\n this._entries.map(entry => {\n entry.zipPostFlight(resp)\n entry.onDone(() => {\n this.numEntriesInProgress--\n if(this.numEntriesInProgress === 0){ this.onComplete() }\n })\n return entry\n })\n\n let groupedEntries = this._entries.reduce((acc, entry) => {\n let {name, callback} = entry.uploader(liveSocket.uploaders)\n acc[name] = acc[name] || {callback: callback, entries: []}\n acc[name].entries.push(entry)\n return acc\n }, {})\n\n for(let name in groupedEntries){\n let {callback, entries} = groupedEntries[name]\n callback(entries, onError, resp, liveSocket)\n }\n }\n}\n", "let ARIA = {\n focusMain(){\n let target = document.querySelector(\"main h1, main, h1\")\n if(target){\n let origTabIndex = target.tabIndex\n target.tabIndex = -1\n target.focus()\n target.tabIndex = origTabIndex\n }\n },\n\n anyOf(instance, classes){ return classes.find(name => instance instanceof name) },\n\n isFocusable(el, interactiveOnly){\n return(\n (el instanceof HTMLAnchorElement && el.rel !== \"ignore\") ||\n (el instanceof HTMLAreaElement && el.href !== undefined) ||\n (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) ||\n (el instanceof HTMLIFrameElement) ||\n (el.tabIndex > 0 || (!interactiveOnly && el.tabIndex === 0 && el.getAttribute(\"tabindex\") !== null && el.getAttribute(\"aria-hidden\") !== \"true\"))\n )\n },\n\n attemptFocus(el, interactiveOnly){\n if(this.isFocusable(el, interactiveOnly)){ try{ el.focus() } catch(e){} }\n return !!document.activeElement && document.activeElement.isSameNode(el)\n },\n\n focusFirstInteractive(el){\n let child = el.firstElementChild\n while(child){\n if(this.attemptFocus(child, true) || this.focusFirstInteractive(child, true)){\n return true\n }\n child = child.nextElementSibling\n }\n },\n\n focusFirst(el){\n let child = el.firstElementChild\n while(child){\n if(this.attemptFocus(child) || this.focusFirst(child)){\n return true\n }\n child = child.nextElementSibling\n }\n },\n\n focusLast(el){\n let child = el.lastElementChild\n while(child){\n if(this.attemptFocus(child) || this.focusLast(child)){\n return true\n }\n child = child.previousElementSibling\n }\n }\n}\nexport default ARIA", "import {\n PHX_ACTIVE_ENTRY_REFS,\n PHX_LIVE_FILE_UPDATED,\n PHX_PREFLIGHTED_REFS,\n PHX_UPLOAD_REF\n} from \"./constants\"\n\nimport LiveUploader from \"./live_uploader\"\nimport ARIA from \"./aria\"\n\nlet Hooks = {\n LiveFileUpload: {\n activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) },\n\n preflightedRefs(){ return this.el.getAttribute(PHX_PREFLIGHTED_REFS) },\n\n mounted(){ this.preflightedWas = this.preflightedRefs() },\n\n updated(){\n let newPreflights = this.preflightedRefs()\n if(this.preflightedWas !== newPreflights){\n this.preflightedWas = newPreflights\n if(newPreflights === \"\"){\n this.__view.cancelSubmit(this.el.form)\n }\n }\n\n if(this.activeRefs() === \"\"){ this.el.value = null }\n this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED))\n }\n },\n\n LiveImgPreview: {\n mounted(){\n this.ref = this.el.getAttribute(\"data-phx-entry-ref\")\n this.inputEl = document.getElementById(this.el.getAttribute(PHX_UPLOAD_REF))\n LiveUploader.getEntryDataURL(this.inputEl, this.ref, url => {\n this.url = url\n this.el.src = url\n })\n },\n destroyed(){\n URL.revokeObjectURL(this.url)\n }\n },\n FocusWrap: {\n mounted(){\n this.focusStart = this.el.firstElementChild\n this.focusEnd = this.el.lastElementChild\n this.focusStart.addEventListener(\"focus\", () => ARIA.focusLast(this.el))\n this.focusEnd.addEventListener(\"focus\", () => ARIA.focusFirst(this.el))\n this.el.addEventListener(\"phx:show-end\", () => this.el.focus())\n if(window.getComputedStyle(this.el).display !== \"none\"){\n ARIA.focusFirst(this.el)\n }\n }\n }\n}\n\nlet scrollTop = () => document.documentElement.scrollTop || document.body.scrollTop\nlet winHeight = () => window.innerHeight || document.documentElement.clientHeight\n\nlet isAtViewportTop = (el) => {\n let rect = el.getBoundingClientRect()\n return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()\n}\n\nlet isAtViewportBottom = (el) => {\n let rect = el.getBoundingClientRect()\n return rect.right >= 0 && rect.left >= 0 && rect.bottom <= winHeight()\n}\n\nlet isWithinViewport = (el) => {\n let rect = el.getBoundingClientRect()\n return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()\n}\n\nHooks.InfiniteScroll = {\n mounted(){\n let scrollBefore = scrollTop()\n let topOverran = false\n let throttleInterval = 500\n let pendingOp = null\n\n let onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => {\n pendingOp = () => true\n this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id, _overran: true}, () => {\n pendingOp = null\n })\n })\n\n let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => {\n pendingOp = () => firstChild.scrollIntoView({block: \"start\"})\n this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id}, () => {\n pendingOp = null\n if(!isWithinViewport(firstChild)){ firstChild.scrollIntoView({block: \"start\"}) }\n })\n })\n\n let onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => {\n pendingOp = () => lastChild.scrollIntoView({block: \"end\"})\n this.liveSocket.execJSHookPush(this.el, bottomEvent, {id: lastChild.id}, () => {\n pendingOp = null\n if(!isWithinViewport(lastChild)){ lastChild.scrollIntoView({block: \"end\"}) }\n })\n })\n\n this.onScroll = (e) => {\n let scrollNow = scrollTop()\n\n if(pendingOp){\n scrollBefore = scrollNow\n return pendingOp()\n }\n let rect = this.el.getBoundingClientRect()\n let topEvent = this.el.getAttribute(this.liveSocket.binding(\"viewport-top\"))\n let bottomEvent = this.el.getAttribute(this.liveSocket.binding(\"viewport-bottom\"))\n let lastChild = this.el.lastElementChild\n let firstChild = this.el.firstElementChild\n let isScrollingUp = scrollNow < scrollBefore\n let isScrollingDown = scrollNow > scrollBefore\n\n // el overran while scrolling up\n if(isScrollingUp && topEvent && !topOverran && rect.top >= 0){\n topOverran = true\n onTopOverrun(topEvent, firstChild)\n } else if(isScrollingDown && topOverran && rect.top <= 0){\n topOverran = false\n }\n\n if(topEvent && isScrollingUp && isAtViewportTop(firstChild)){\n onFirstChildAtTop(topEvent, firstChild)\n } else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild)){\n onLastChildAtBottom(bottomEvent, lastChild)\n }\n scrollBefore = scrollNow\n }\n window.addEventListener(\"scroll\", this.onScroll)\n },\n destroyed(){ window.removeEventListener(\"scroll\", this.onScroll) },\n\n throttle(interval, callback){\n let lastCallAt = 0\n let timer\n\n return (...args) => {\n let now = Date.now()\n let remainingTime = interval - (now - lastCallAt)\n\n if(remainingTime <= 0 || remainingTime > interval){\n if(timer) {\n clearTimeout(timer)\n timer = null\n }\n lastCallAt = now\n callback(...args)\n } else if(!timer){\n timer = setTimeout(() => {\n lastCallAt = Date.now()\n timer = null\n callback(...args)\n }, remainingTime)\n }\n }\n }\n}\nexport default Hooks\n", "import {\n maybe\n} from \"./utils\"\n\nimport DOM from \"./dom\"\n\nexport default class DOMPostMorphRestorer {\n constructor(containerBefore, containerAfter, updateType){\n let idsBefore = new Set()\n let idsAfter = new Set([...containerAfter.children].map(child => child.id))\n\n let elementsToModify = []\n\n Array.from(containerBefore.children).forEach(child => {\n if(child.id){ // all of our children should be elements with ids\n idsBefore.add(child.id)\n if(idsAfter.has(child.id)){\n let previousElementId = child.previousElementSibling && child.previousElementSibling.id\n elementsToModify.push({elementId: child.id, previousElementId: previousElementId})\n }\n }\n })\n\n this.containerId = containerAfter.id\n this.updateType = updateType\n this.elementsToModify = elementsToModify\n this.elementIdsToAdd = [...idsAfter].filter(id => !idsBefore.has(id))\n }\n\n // We do the following to optimize append/prepend operations:\n // 1) Track ids of modified elements & of new elements\n // 2) All the modified elements are put back in the correct position in the DOM tree\n // by storing the id of their previous sibling\n // 3) New elements are going to be put in the right place by morphdom during append.\n // For prepend, we move them to the first position in the container\n perform(){\n let container = DOM.byId(this.containerId)\n this.elementsToModify.forEach(elementToModify => {\n if(elementToModify.previousElementId){\n maybe(document.getElementById(elementToModify.previousElementId), previousElem => {\n maybe(document.getElementById(elementToModify.elementId), elem => {\n let isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id\n if(!isInRightPlace){\n previousElem.insertAdjacentElement(\"afterend\", elem)\n }\n })\n })\n } else {\n // This is the first element in the container\n maybe(document.getElementById(elementToModify.elementId), elem => {\n let isInRightPlace = elem.previousElementSibling == null\n if(!isInRightPlace){\n container.insertAdjacentElement(\"afterbegin\", elem)\n }\n })\n }\n })\n\n if(this.updateType == \"prepend\"){\n this.elementIdsToAdd.reverse().forEach(elemId => {\n maybe(document.getElementById(elemId), elem => container.insertAdjacentElement(\"afterbegin\", elem))\n })\n }\n }\n}\n", "var DOCUMENT_FRAGMENT_NODE = 11;\n\nfunction morphAttrs(fromNode, toNode) {\n var toNodeAttrs = toNode.attributes;\n var attr;\n var attrName;\n var attrNamespaceURI;\n var attrValue;\n var fromValue;\n\n // document-fragments dont have attributes so lets not do anything\n if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) {\n return;\n }\n\n // update attributes on original DOM element\n for (var i = toNodeAttrs.length - 1; i >= 0; i--) {\n attr = toNodeAttrs[i];\n attrName = attr.name;\n attrNamespaceURI = attr.namespaceURI;\n attrValue = attr.value;\n\n if (attrNamespaceURI) {\n attrName = attr.localName || attrName;\n fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName);\n\n if (fromValue !== attrValue) {\n if (attr.prefix === 'xmlns'){\n attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix\n }\n fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue);\n }\n } else {\n fromValue = fromNode.getAttribute(attrName);\n\n if (fromValue !== attrValue) {\n fromNode.setAttribute(attrName, attrValue);\n }\n }\n }\n\n // Remove any extra attributes found on the original DOM element that\n // weren't found on the target element.\n var fromNodeAttrs = fromNode.attributes;\n\n for (var d = fromNodeAttrs.length - 1; d >= 0; d--) {\n attr = fromNodeAttrs[d];\n attrName = attr.name;\n attrNamespaceURI = attr.namespaceURI;\n\n if (attrNamespaceURI) {\n attrName = attr.localName || attrName;\n\n if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) {\n fromNode.removeAttributeNS(attrNamespaceURI, attrName);\n }\n } else {\n if (!toNode.hasAttribute(attrName)) {\n fromNode.removeAttribute(attrName);\n }\n }\n }\n}\n\nvar range; // Create a range object for efficently rendering strings to elements.\nvar NS_XHTML = 'http://www.w3.org/1999/xhtml';\n\nvar doc = typeof document === 'undefined' ? undefined : document;\nvar HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template');\nvar HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange();\n\nfunction createFragmentFromTemplate(str) {\n var template = doc.createElement('template');\n template.innerHTML = str;\n return template.content.childNodes[0];\n}\n\nfunction createFragmentFromRange(str) {\n if (!range) {\n range = doc.createRange();\n range.selectNode(doc.body);\n }\n\n var fragment = range.createContextualFragment(str);\n return fragment.childNodes[0];\n}\n\nfunction createFragmentFromWrap(str) {\n var fragment = doc.createElement('body');\n fragment.innerHTML = str;\n return fragment.childNodes[0];\n}\n\n/**\n * This is about the same\n * var html = new DOMParser().parseFromString(str, 'text/html');\n * return html.body.firstChild;\n *\n * @method toElement\n * @param {String} str\n */\nfunction toElement(str) {\n str = str.trim();\n if (HAS_TEMPLATE_SUPPORT) {\n // avoid restrictions on content for things like `Hi` which\n // createContextualFragment doesn't support\n //